started work on scheduling
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
2026-07-01 10:07:20 +02:00
parent 70dca17451
commit c5c61f22ad
13 changed files with 414 additions and 4 deletions

View File

@ -53,6 +53,8 @@ $routes = [
'program_sessions' => __DIR__ . '/../handlers/program_sessions.php', 'program_sessions' => __DIR__ . '/../handlers/program_sessions.php',
'coaches' => __DIR__ . '/../handlers/coaches.php', 'coaches' => __DIR__ . '/../handlers/coaches.php',
'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php', 'app_questionnaires' => __DIR__ . '/../handlers/app_questionnaires.php',
'app_session_schedule' => __DIR__ . '/../handlers/app_session_schedule.php',
'schedule' => __DIR__ . '/../handlers/schedule.php',
'logout' => __DIR__ . '/../handlers/logout.php', 'logout' => __DIR__ . '/../handlers/logout.php',
'session' => __DIR__ . '/../handlers/session.php', 'session' => __DIR__ . '/../handlers/session.php',
'auth/login' => __DIR__ . '/../handlers/auth.php', 'auth/login' => __DIR__ . '/../handlers/auth.php',

View File

@ -126,6 +126,18 @@
"review_scores_total", "review_scores_total",
"save", "save",
"save_password_btn", "save_password_btn",
"schedule_confirm",
"schedule_date_must_be_future",
"schedule_hub_date_heading",
"schedule_later",
"schedule_next_session_body",
"schedule_next_session_eyebrow",
"schedule_next_session_title",
"schedule_pick_date_label",
"schedule_reminder_today",
"schedule_reminder_today_title",
"schedule_reminder_tomorrow",
"schedule_reminder_tomorrow_title",
"select_one_answer", "select_one_answer",
"select_one_answer_per_row", "select_one_answer_per_row",
"september", "september",
@ -273,6 +285,18 @@
"review_scores_total": "Gewichtete Summe", "review_scores_total": "Gewichtete Summe",
"save": "Speichern", "save": "Speichern",
"save_password_btn": "Passwort speichern", "save_password_btn": "Passwort speichern",
"schedule_confirm": "Termin speichern",
"schedule_date_must_be_future": "Datum muss in der Zukunft liegen",
"schedule_hub_date_heading": "Geplant · %s",
"schedule_later": "Später planen",
"schedule_next_session_body": "Nächster Termin für diese Sitzung",
"schedule_next_session_eyebrow": "Nächste Sitzung",
"schedule_next_session_title": "Termin planen",
"schedule_pick_date_label": "Datum wählen",
"schedule_reminder_today": "%d Klient(en) haben heute einen Termin",
"schedule_reminder_today_title": "Termine heute",
"schedule_reminder_tomorrow": "%d Klient(en) haben morgen einen Termin",
"schedule_reminder_tomorrow_title": "Termine morgen",
"select_one_answer": "Bitte wählen Sie eine Antwort aus!", "select_one_answer": "Bitte wählen Sie eine Antwort aus!",
"select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!", "select_one_answer_per_row": "Bitte wählen Sie eine Antwort pro Reihe aus!",
"september": "September", "september": "September",

View File

@ -13,7 +13,7 @@ if (defined('QDB_TEST_UPLOADS')) {
define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock'); define('QDB_LOCK', QDB_UPLOADS_DIR . '/.qdb_lock');
} }
define('QDB_SCHEMA', __DIR__ . '/schema.sql'); define('QDB_SCHEMA', __DIR__ . '/schema.sql');
define('QDB_VERSION', 15); define('QDB_VERSION', 16);
function qdb_table_exists(PDO $pdo, string $table): bool { function qdb_table_exists(PDO $pdo, string $table): bool {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
@ -136,6 +136,22 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
} }
} }
if (!qdb_table_exists($pdo, 'client_session_schedule')) {
$pdo->exec("
CREATE TABLE client_session_schedule (
clientCode TEXT NOT NULL PRIMARY KEY,
scheduledDate TEXT NOT NULL,
programCycleNumber INTEGER NOT NULL,
programSessionNumber INTEGER NOT NULL,
programSessionID TEXT,
updatedAt INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE
)
");
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_client_session_schedule_date ON client_session_schedule(scheduledDate)');
$changed = true;
}
if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot') if (qdb_table_exists($pdo, 'questionnaire_structure_snapshot')
&& qdb_table_exists($pdo, 'questionnaire')) { && qdb_table_exists($pdo, 'questionnaire')) {
require_once __DIR__ . '/lib/questionnaire_structure.php'; require_once __DIR__ . '/lib/questionnaire_structure.php';

View File

@ -514,11 +514,13 @@ if ($fetchClients) {
} }
require_once __DIR__ . '/../lib/program_sessions.php'; require_once __DIR__ . '/../lib/program_sessions.php';
require_once __DIR__ . '/../lib/session_schedule.php';
$clients = array_map(function ($code) use ($completions, $pdo) { $clients = array_map(function ($code) use ($completions, $pdo) {
return [ return [
'clientCode' => $code, 'clientCode' => $code,
'completedQuestionnaires' => $completions[$code] ?? [], 'completedQuestionnaires' => $completions[$code] ?? [],
'programProgress' => qdb_client_program_progress($pdo, $code), 'programProgress' => qdb_client_program_progress($pdo, $code),
'nextSessionSchedule' => qdb_get_client_session_schedule($pdo, $code),
]; ];
}, $clientCodes); }, $clientCodes);

View File

@ -0,0 +1,56 @@
<?php
$tokenRec = require_valid_token();
if ($method !== 'POST') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
$body = read_json_body();
require_fields($body, ['clientCode', 'scheduledDate', 'programCycleNumber', 'programSessionNumber']);
$clientCode = trim((string)$body['clientCode']);
$scheduledDate = trim((string)$body['scheduledDate']);
$programCycleNumber = max(1, (int)$body['programCycleNumber']);
$programSessionNumber = max(1, (int)$body['programSessionNumber']);
$programSessionID = isset($body['programSessionID'])
? trim((string)$body['programSessionID'])
: null;
if ($programSessionID === '') {
$programSessionID = null;
}
try {
$opened = qdb_open_write_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
require_role(['coach'], $tokenRec);
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$clStmt = $pdo->prepare(
"SELECT cl.clientCode FROM client cl WHERE cl.clientCode = :cc AND ($rbacClause)"
);
$clStmt->execute(array_merge([':cc' => $clientCode], $rbacParams));
if (!$clStmt->fetch()) {
qdb_discard($tmpDb, $lockFp);
json_error('NOT_FOUND', 'Client not found or not authorized', 404);
}
require_once __DIR__ . '/../lib/session_schedule.php';
qdb_upsert_client_session_schedule(
$pdo,
$clientCode,
$scheduledDate,
$programCycleNumber,
$programSessionNumber,
$programSessionID
);
qdb_save($tmpDb, $lockFp);
json_success([
'scheduled' => true,
'schedule' => qdb_get_client_session_schedule($pdo, $clientCode),
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Save session schedule', null, $tmpDb ?? null, $lockFp ?? null);
}

View File

@ -19,7 +19,14 @@ case 'GET':
INNER JOIN client cl ON cl.clientCode = cq.clientCode INNER JOIN client cl ON cl.clientCode = cq.clientCode
WHERE cq.questionnaireID = q.questionnaireID WHERE cq.questionnaireID = q.questionnaireID
AND $rbacClause AND $rbacClause
) AS completedCount ) AS completedCount,
(
SELECT COUNT(*)
FROM questionnaire_submission qs
INNER JOIN client cl ON cl.clientCode = qs.clientCode
WHERE qs.questionnaireID = q.questionnaireID
AND $rbacClause
) AS submissionCount
FROM questionnaire q FROM questionnaire q
LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID LEFT JOIN question qu ON qu.questionnaireID = q.questionnaireID
GROUP BY q.questionnaireID GROUP BY q.questionnaireID
@ -33,6 +40,7 @@ case 'GET':
$r['showPoints'] = (int)$r['showPoints']; $r['showPoints'] = (int)$r['showPoints'];
$r['questionCount'] = (int)$r['questionCount']; $r['questionCount'] = (int)$r['questionCount'];
$r['completedCount'] = (int)$r['completedCount']; $r['completedCount'] = (int)$r['completedCount'];
$r['submissionCount'] = (int)$r['submissionCount'];
$r['conditionJson'] = $r['conditionJson'] ?: '{}'; $r['conditionJson'] = $r['conditionJson'] ?: '{}';
} }
unset($r); unset($r);

31
handlers/schedule.php Normal file
View File

@ -0,0 +1,31 @@
<?php
$tokenRec = require_valid_token_web();
require_role(['admin', 'supervisor'], $tokenRec);
if ($method !== 'GET') {
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
}
try {
$opened = qdb_open_read_or_fail();
[$pdo, $tmpDb, $lockFp] = $opened;
require_once __DIR__ . '/../lib/session_schedule.php';
$entries = qdb_list_session_schedules_scoped($pdo, $tokenRec);
$byDate = [];
foreach ($entries as $entry) {
$date = $entry['scheduledDate'];
$byDate[$date][] = $entry;
}
qdb_discard($tmpDb, $lockFp);
json_success([
'entries' => $entries,
'byDate' => $byDate,
]);
} catch (Throwable $e) {
qdb_handler_fail($e, 'Load schedule', null, $tmpDb ?? null, $lockFp ?? null);
}

140
lib/session_schedule.php Normal file
View File

@ -0,0 +1,140 @@
<?php
/**
* Next program session appointments (date-only, DD-MM-YYYY).
*/
/** @return bool */
function qdb_scheduled_date_format_valid(string $date): bool
{
if (!preg_match('/^\d{2}-\d{2}-\d{4}$/', $date)) {
return false;
}
[$d, $m, $y] = array_map('intval', explode('-', $date));
return checkdate($m, $d, $y);
}
/** Start of calendar day in server default timezone. */
function qdb_scheduled_date_to_start_of_day_unix(string $date): ?int
{
if (!qdb_scheduled_date_format_valid($date)) {
return null;
}
[$d, $m, $y] = explode('-', $date);
$ts = mktime(0, 0, 0, (int)$m, (int)$d, (int)$y);
return $ts !== false ? $ts : null;
}
function qdb_scheduled_date_is_after_today(string $date, ?int $nowTs = null): bool
{
$dayStart = qdb_scheduled_date_to_start_of_day_unix($date);
if ($dayStart === null) {
return false;
}
$nowTs ??= time();
$todayStart = strtotime('today', $nowTs);
return $dayStart > $todayStart;
}
/**
* @return array{scheduledDate: string, programCycleNumber: int, programSessionNumber: int, programSessionID: ?string, updatedAt: int}|null
*/
function qdb_get_client_session_schedule(PDO $pdo, string $clientCode): ?array
{
$stmt = $pdo->prepare(
'SELECT scheduledDate, programCycleNumber, programSessionNumber, programSessionID, updatedAt
FROM client_session_schedule WHERE clientCode = :cc'
);
$stmt->execute([':cc' => $clientCode]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
return [
'scheduledDate' => (string)$row['scheduledDate'],
'programCycleNumber' => (int)$row['programCycleNumber'],
'programSessionNumber' => (int)$row['programSessionNumber'],
'programSessionID' => $row['programSessionID'] !== null && $row['programSessionID'] !== ''
? (string)$row['programSessionID']
: null,
'updatedAt' => (int)$row['updatedAt'],
];
}
function qdb_upsert_client_session_schedule(
PDO $pdo,
string $clientCode,
string $scheduledDate,
int $programCycleNumber,
int $programSessionNumber,
?string $programSessionID = null
): void {
if (!qdb_scheduled_date_format_valid($scheduledDate)) {
json_error('INVALID_FIELD', 'scheduledDate must be DD-MM-YYYY', 400);
}
if (!qdb_scheduled_date_is_after_today($scheduledDate)) {
json_error('INVALID_FIELD', 'scheduledDate must be in the future', 400);
}
if ($programCycleNumber < 1 || $programSessionNumber < 1) {
json_error('INVALID_FIELD', 'program cycle and session must be positive', 400);
}
$pdo->prepare(
'INSERT INTO client_session_schedule (
clientCode, scheduledDate, programCycleNumber, programSessionNumber, programSessionID, updatedAt
) VALUES (
:cc, :sd, :pcn, :psn, :psid, :uat
)
ON CONFLICT(clientCode) DO UPDATE SET
scheduledDate = excluded.scheduledDate,
programCycleNumber = excluded.programCycleNumber,
programSessionNumber = excluded.programSessionNumber,
programSessionID = excluded.programSessionID,
updatedAt = excluded.updatedAt'
)->execute([
':cc' => $clientCode,
':sd' => $scheduledDate,
':pcn' => $programCycleNumber,
':psn' => $programSessionNumber,
':psid' => $programSessionID,
':uat' => time(),
]);
}
/**
* RBAC-scoped schedule rows for the website Schedule view.
*
* @return list<array<string, mixed>>
*/
function qdb_list_session_schedules_scoped(PDO $pdo, array $tokenRec): array
{
[$rbacClause, $rbacParams] = rbac_client_filter($tokenRec, 'cl');
$sql = "
SELECT css.clientCode, css.scheduledDate, css.programCycleNumber, css.programSessionNumber,
css.programSessionID, css.updatedAt,
cl.coachID, co.username AS coachUsername,
sv.username AS supervisorUsername
FROM client_session_schedule css
INNER JOIN client cl ON cl.clientCode = css.clientCode
LEFT JOIN coach co ON co.coachID = cl.coachID
LEFT JOIN supervisor sv ON sv.supervisorID = co.supervisorID
WHERE ($rbacClause)
ORDER BY css.scheduledDate ASC, co.username ASC, css.clientCode ASC
";
$stmt = $pdo->prepare($sql);
$stmt->execute($rbacParams);
$rows = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$rows[] = [
'clientCode' => $row['clientCode'],
'scheduledDate' => $row['scheduledDate'],
'programCycleNumber' => (int)$row['programCycleNumber'],
'programSessionNumber' => (int)$row['programSessionNumber'],
'programSessionID' => $row['programSessionID'],
'coachID' => $row['coachID'],
'coachUsername' => $row['coachUsername'] ?? $row['coachID'],
'supervisorUsername' => $row['supervisorUsername'] ?? '',
'updatedAt' => (int)$row['updatedAt'],
];
}
return $rows;
}

View File

@ -294,3 +294,15 @@ CREATE TABLE IF NOT EXISTS client_program_cycle (
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS client_session_schedule (
clientCode TEXT NOT NULL PRIMARY KEY,
scheduledDate TEXT NOT NULL,
programCycleNumber INTEGER NOT NULL,
programSessionNumber INTEGER NOT NULL,
programSessionID TEXT,
updatedAt INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(clientCode) REFERENCES client(clientCode) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_client_session_schedule_date
ON client_session_schedule(scheduledDate);

View File

@ -29,6 +29,12 @@
Sessions Sessions
</a> </a>
</li> </li>
<li data-nav-roles="admin supervisor">
<a href="#/schedule">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01"/></svg>
Schedule
</a>
</li>
<li> <li>
<a href="#/questionnaires"> <a href="#/questionnaires">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/></svg>

View File

@ -14,6 +14,7 @@ import { devPage } from './pages/dev.js';
import { insightsPage } from './pages/insights.js'; import { insightsPage } from './pages/insights.js';
import { coachesPage } from './pages/coaches.js'; import { coachesPage } from './pages/coaches.js';
import { sessionsPage } from './pages/sessions.js'; import { sessionsPage } from './pages/sessions.js';
import { schedulePage } from './pages/schedule.js';
import { openChangeOwnPasswordModal } from './password-modal.js'; import { openChangeOwnPasswordModal } from './password-modal.js';
// Auth state // Auth state
@ -136,6 +137,9 @@ function updateNav() {
if (!active && href === '/sessions' && hash.startsWith('/sessions')) { if (!active && href === '/sessions' && hash.startsWith('/sessions')) {
active = true; active = true;
} }
if (!active && href === '/schedule' && hash.startsWith('/schedule')) {
active = true;
}
if (!active && href !== '/' && hash.startsWith(href)) { if (!active && href !== '/' && hash.startsWith(href)) {
active = true; active = true;
} }
@ -160,6 +164,7 @@ addRoute('/login', loginPage);
addRoute('/', roleGuard(['admin', 'supervisor'], homeDashboardPage)); addRoute('/', roleGuard(['admin', 'supervisor'], homeDashboardPage));
addRoute('/questionnaires', authGuard(questionnairesPage)); addRoute('/questionnaires', authGuard(questionnairesPage));
addRoute('/sessions', roleGuard(['admin', 'supervisor'], sessionsPage)); addRoute('/sessions', roleGuard(['admin', 'supervisor'], sessionsPage));
addRoute('/schedule', roleGuard(['admin', 'supervisor'], schedulePage));
addRoute('/questionnaire/new', authGuard(editorPage)); addRoute('/questionnaire/new', authGuard(editorPage));
addRoute('/questionnaire/:id/results', authGuard(resultsPage)); addRoute('/questionnaire/:id/results', authGuard(resultsPage));
addRoute('/questionnaire/:id', authGuard(editorPage)); addRoute('/questionnaire/:id', authGuard(editorPage));

View File

@ -62,7 +62,7 @@ function renderExport() {
<th>Version</th> <th>Version</th>
<th>State</th> <th>State</th>
<th>Questions</th> <th>Questions</th>
<th>Completed</th> <th>Uploaded responses</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@ -133,7 +133,7 @@ function exportRowHTML(q) {
<td>${esc(q.version || '—')}</td> <td>${esc(q.version || '—')}</td>
<td><span class="badge badge-${(q.state || 'draft').toLowerCase()}">${esc(q.state || 'draft')}</span></td> <td><span class="badge badge-${(q.state || 'draft').toLowerCase()}">${esc(q.state || 'draft')}</span></td>
<td>${q.questionCount || 0}</td> <td>${q.questionCount || 0}</td>
<td>${q.completedCount ?? 0}</td> <td>${q.submissionCount ?? 0}</td>
<td class="export-actions-cell"> <td class="export-actions-cell">
<button class="btn btn-sm btn-primary export-btn" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}" data-version="${esc(q.version)}" data-all="0"> <button class="btn btn-sm btn-primary export-btn" data-id="${esc(q.questionnaireID)}" data-name="${esc(q.name)}" data-version="${esc(q.version)}" data-all="0">
Export current Export current

View File

@ -0,0 +1,108 @@
import { apiGet } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
export async function schedulePage() {
const app = document.getElementById('app');
app.innerHTML = `
${pageHeaderHTML('Schedule', '', {
subtitle: 'Upcoming session appointments set by counselors in the app',
})}
<div id="scheduleContent"><div class="spinner"></div></div>
`;
try {
const data = await apiGet('schedule');
renderSchedule(data.entries || [], data.byDate || {});
} catch (e) {
showToast(e.message, 'error');
document.getElementById('scheduleContent').innerHTML =
`<p class="error-text">${esc(e.message)}</p>`;
}
}
function renderSchedule(entries, byDate) {
const el = document.getElementById('scheduleContent');
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const futureEntries = entries.filter(e => parseDdMmYyyy(e.scheduledDate) > todayStart.getTime());
const futureByDate = {};
futureEntries.forEach(e => {
(futureByDate[e.scheduledDate] ||= []).push(e);
});
if (!futureEntries.length) {
el.innerHTML = `
<div class="card">
<p class="scoring-empty-hint" style="margin:0">No upcoming session appointments.</p>
</div>`;
return;
}
const dates = Object.keys(futureByDate).sort((a, b) => parseDdMmYyyy(a) - parseDdMmYyyy(b));
el.innerHTML = `
<div class="card" style="margin-bottom:16px">
<p class="data-toolbar-hint" style="margin:0">
${futureEntries.length} scheduled client${futureEntries.length === 1 ? '' : 's'} across ${dates.length} date${dates.length === 1 ? '' : 's'}.
Counselors set dates in the mobile app.
</p>
</div>
${dates.map(date => dateGroupHTML(date, futureByDate[date] || [])).join('')}
`;
}
function dateGroupHTML(scheduledDate, rows) {
const sorted = [...rows].sort((a, b) =>
String(a.coachUsername || '').localeCompare(String(b.coachUsername || ''))
|| String(a.clientCode).localeCompare(String(b.clientCode)),
);
return `
<div class="card scoring-profiles-card" style="margin-bottom:16px">
<div class="scoring-profile-header">
<div>
<h3 class="scoring-section-title">${esc(scheduledDate)}</h3>
<p class="scoring-section-desc">
${sorted.length} client${sorted.length === 1 ? '' : 's'}
</p>
</div>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Client</th>
<th>Cycle</th>
<th>Session</th>
<th>Counselor</th>
<th>Supervisor</th>
</tr>
</thead>
<tbody>
${sorted.map(row => `
<tr>
<td>${esc(row.clientCode)}</td>
<td>${esc(String(row.programCycleNumber))}</td>
<td>${esc(String(row.programSessionNumber))}</td>
<td>${esc(row.coachUsername || row.coachID || '—')}</td>
<td>${esc(row.supervisorUsername || '—')}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>`;
}
function parseDdMmYyyy(date) {
const m = /^(\d{2})-(\d{2})-(\d{4})$/.exec(String(date));
if (!m) return 0;
return new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])).getTime();
}
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}