explore.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. import { mkdir, readdir, rm, stat } from 'node:fs/promises'
  2. import path from 'node:path'
  3. import { z } from 'zod'
  4. import { dataRoot } from '@/lib/upload-root'
  5. import { type OpenedTarget, openTarget, type RunTarget } from './engine'
  6. import { MAX_OPEN_RESTORE_POINTS as MAX_OPEN, maxDataFileBytes } from './limits'
  7. import { UPLOAD_CATEGORIES } from './manifest'
  8. import { type ResticFindMatch, resticDumpToFile, resticFind } from './restic'
  9. import type { ExploreStage } from '@/features/integrations/Lib/backup-types'
  10. export type { ExploreStage }
  11. import { readJsonFile } from './zip-file'
  12. /**
  13. * Looking inside a restore point without restoring it.
  14. *
  15. * A snapshot holds one `data.json` (every row of the workshop, megabytes)
  16. * and the uploads folder. Opening a restore point pulls only the data file
  17. * out of the repository, parses it and removes it from disk at once (it can
  18. * hold secret keys), and keeps the parsed rows in memory for a quarter of an
  19. * hour (two restore points at most, and a data file no larger than
  20. * lib/backup/limits.ts allows: the whole of it is parsed at once); from
  21. * there the explorer lists customers, vehicles, work orders, quotes and parts
  22. * as they were, and searches them. A photo or document is pulled out one at
  23. * a time when it is opened. Nothing here writes to the workshop.
  24. *
  25. * Scope: a restore point is only ever opened through the workshop's own
  26. * repository (its platform one, or a destination it connected), and a file
  27. * is only looked for under that workshop's uploads folder in the snapshot.
  28. */
  29. /** Parsed restore points are dropped after this, and their folder with them. */
  30. const KEEP_OPEN_MS = 15 * 60 * 1000
  31. /** A single upload pulled out of a snapshot. */
  32. const MAX_FILE_BYTES = 200 * 1024 * 1024
  33. const SNAPSHOT_ID = /^[0-9a-f]{8,64}$/
  34. const FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/
  35. export type ExploreDataset =
  36. | 'customers'
  37. | 'vehicles'
  38. | 'serviceRecords'
  39. | 'quotes'
  40. | 'inventoryParts'
  41. export const EXPLORE_DATASETS: ExploreDataset[] = [
  42. 'customers',
  43. 'vehicles',
  44. 'serviceRecords',
  45. 'quotes',
  46. 'inventoryParts',
  47. ]
  48. type Row = Record<string, unknown>
  49. interface OpenPoint {
  50. key: string
  51. organizationId: string
  52. /**
  53. * The workshop id the snapshot was taken under: this one, or the id an
  54. * earlier install of the workshop had (a destination connected again from
  55. * a new server). Its uploads in the snapshot sit under that id.
  56. */
  57. sourceOrganizationId: string
  58. target: RunTarget
  59. snapshotId: string
  60. openedAt: number
  61. dir: string
  62. exportedAt: string | null
  63. version: number
  64. /** The datasets the explorer shows, as arrays of rows; work orders are lifted out of their vehicles. */
  65. rows: Record<ExploreDataset, Row[]>
  66. byId: Record<ExploreDataset, Map<string, Row>>
  67. }
  68. const g = globalThis as unknown as {
  69. __torqvoiceExplore?: Map<string, OpenPoint>
  70. __torqvoiceExploreOpening?: Map<string, Promise<RestorePointSummary>>
  71. __torqvoiceExploreStage?: Map<string, ExploreStage>
  72. }
  73. function open(): Map<string, OpenPoint> {
  74. if (!g.__torqvoiceExplore) g.__torqvoiceExplore = new Map()
  75. return g.__torqvoiceExplore
  76. }
  77. /** Opens in flight, so two requests for one snapshot share one download. */
  78. function opening(): Map<string, Promise<RestorePointSummary>> {
  79. if (!g.__torqvoiceExploreOpening) g.__torqvoiceExploreOpening = new Map()
  80. return g.__torqvoiceExploreOpening
  81. }
  82. /** The stage each open in flight has reached, for the page to show. */
  83. function stages(): Map<string, ExploreStage> {
  84. if (!g.__torqvoiceExploreStage) g.__torqvoiceExploreStage = new Map()
  85. return g.__torqvoiceExploreStage
  86. }
  87. /**
  88. * The stage an open of this workshop's restore point has reached, or null
  89. * when none is in flight (not started, or already open).
  90. */
  91. export function openingStage(organizationId: string, snapshotId: string): ExploreStage | null {
  92. return stages().get(keyOf(organizationId, snapshotId)) ?? null
  93. }
  94. const ORGANIZATION_ID = /^[a-z0-9]+$/i
  95. function exploreRoot(): string {
  96. return path.join(dataRoot(), 'restore-explore')
  97. }
  98. export function exploreDirFor(organizationId: string, snapshotId: string): string {
  99. if (!ORGANIZATION_ID.test(organizationId) || !SNAPSHOT_ID.test(snapshotId)) {
  100. throw new Error('Unexpected id')
  101. }
  102. return path.join(exploreRoot(), organizationId, snapshotId)
  103. }
  104. function keyOf(organizationId: string, snapshotId: string) {
  105. return `${organizationId}:${snapshotId}`
  106. }
  107. async function forget(point: OpenPoint): Promise<void> {
  108. open().delete(point.key)
  109. await rm(point.dir, { recursive: true, force: true }).catch(() => undefined)
  110. }
  111. /**
  112. * Drops restore points open too long and, with `reserve` above zero, makes
  113. * room for that many opens by closing the oldest parsed points beyond the
  114. * cap. An open in flight holds a parsed file before it is listed as open, so
  115. * a caller reserves its own place and those of the opens registered ahead of
  116. * it. Then removes every folder on disk that no open restore point owns: the
  117. * registry lives in this process only, so after a restart the folders of the
  118. * points open before it are found here.
  119. */
  120. async function sweep(reserve: number, now = Date.now()): Promise<void> {
  121. const points = [...open().values()].sort((a, b) => a.openedAt - b.openedAt)
  122. for (const p of points) if (now - p.openedAt > KEEP_OPEN_MS) await forget(p)
  123. if (reserve > 0) {
  124. const left = [...open().values()].sort((a, b) => a.openedAt - b.openedAt)
  125. while (left.length > 0 && left.length + reserve > MAX_OPEN) {
  126. const oldest = left.shift()
  127. if (oldest) await forget(oldest)
  128. }
  129. }
  130. await sweepOrphans()
  131. }
  132. export const RESTORE_POINTS_BUSY =
  133. 'Other restore points are being opened on this server right now; try again in a moment.'
  134. /**
  135. * Removes the `<orgId>/<snapshotId>` folders under the explore root that no
  136. * open restore point owns and no open in flight is filling. Only names that
  137. * `exploreDirFor` accepts are touched, so nothing outside the root is.
  138. */
  139. async function sweepOrphans(): Promise<void> {
  140. const orgs = await readdir(exploreRoot(), { withFileTypes: true }).catch(() => [])
  141. for (const org of orgs) {
  142. if (!org.isDirectory() || !ORGANIZATION_ID.test(org.name)) continue
  143. const snaps = await readdir(path.join(exploreRoot(), org.name), {
  144. withFileTypes: true,
  145. }).catch(() => [])
  146. for (const snap of snaps) {
  147. if (!snap.isDirectory() || !SNAPSHOT_ID.test(snap.name)) continue
  148. const key = keyOf(org.name, snap.name)
  149. if (open().has(key) || opening().has(key)) continue
  150. const dir = exploreDirFor(org.name, snap.name)
  151. await rm(dir, { recursive: true, force: true }).catch(() => undefined)
  152. }
  153. }
  154. }
  155. /** Closes every open restore point of a workshop, before a restore replaces it. */
  156. export async function closeRestorePoints(organizationId: string): Promise<void> {
  157. for (const p of [...open().values()]) if (p.organizationId === organizationId) await forget(p)
  158. }
  159. /**
  160. * Closes restore points open longer than a quarter of an hour and removes
  161. * their folders, plus any folder no open point owns. The same sweep runs
  162. * whenever a restore point is opened; the backup cron calls this one so the
  163. * files of the last viewer do not wait for the next.
  164. */
  165. export async function sweepRestorePoints(now = Date.now()): Promise<void> {
  166. await sweep(0, now)
  167. }
  168. /**
  169. * The staged data file inside the snapshot: `…/torqvoice-backup-staging/<orgId>/data.json`
  170. * as it was on the server that took it. Never a `data.json` someone uploaded.
  171. * The repository is this workshop's own, so the id in the path may be the
  172. * one an earlier install of the workshop had (a destination connected again
  173. * from a new server); this workshop's own is preferred when both exist.
  174. */
  175. function isStagedDataFile(p: string): boolean {
  176. const parts = p.split('/')
  177. return parts.at(-1) === 'data.json' && parts.at(-3) === 'torqvoice-backup-staging'
  178. }
  179. const backupShape = z.object({
  180. version: z.number(),
  181. exportedAt: z.string().optional(),
  182. contents: z.enum(['data', 'dataAndSecrets']).optional(),
  183. data: z.record(z.string(), z.unknown()),
  184. })
  185. function rowsOf(value: unknown): Row[] {
  186. return Array.isArray(value)
  187. ? value.filter((r): r is Row => Boolean(r) && typeof r === 'object')
  188. : []
  189. }
  190. function index(rows: Row[]): Map<string, Row> {
  191. const m = new Map<string, Row>()
  192. for (const r of rows) if (typeof r.id === 'string') m.set(r.id, r)
  193. return m
  194. }
  195. /**
  196. * Opens a restore point: pulls its data file out of the repository (once),
  197. * parses it and keeps it for a while. `target` is the workshop's own
  198. * repository; the organisation id is taken from it, never from the caller.
  199. */
  200. export async function openRestorePoint(
  201. target: RunTarget,
  202. snapshotId: string
  203. ): Promise<RestorePointSummary> {
  204. if (!SNAPSHOT_ID.test(snapshotId)) throw new Error('Invalid snapshot id')
  205. const opened: OpenedTarget = await openTarget(target)
  206. const organizationId = opened.organizationId
  207. const key = keyOf(organizationId, snapshotId)
  208. const existing = open().get(key)
  209. if (existing) {
  210. existing.openedAt = Date.now()
  211. await sweep(0)
  212. return summaryOf(existing)
  213. }
  214. const inFlight = opening().get(key)
  215. if (inFlight) return inFlight
  216. // Listed as in flight before any work starts, so a second request for the
  217. // same snapshot joins this one and the cap sees this open from the start.
  218. let settle!: { resolve: (v: RestorePointSummary) => void; reject: (e: unknown) => void }
  219. const promise = new Promise<RestorePointSummary>((resolve, reject) => {
  220. settle = { resolve, reject }
  221. })
  222. opening().set(key, promise)
  223. ;(async () => {
  224. // This open and the ones registered before it that are still in flight:
  225. // the map keeps insertion order, so the place in it is the place in line.
  226. const reserve = [...opening().keys()].indexOf(key) + 1
  227. await sweep(reserve)
  228. // Nothing left to close and still over the cap: the rest are in flight.
  229. if (open().size + reserve > MAX_OPEN) throw new Error(RESTORE_POINTS_BUSY)
  230. const dir = exploreDirFor(organizationId, snapshotId)
  231. await rm(dir, { recursive: true, force: true })
  232. await mkdir(dir, { recursive: true, mode: 0o700 })
  233. try {
  234. return await pull(opened, target, organizationId, snapshotId, key, dir)
  235. } catch (err) {
  236. await rm(dir, { recursive: true, force: true }).catch(() => undefined)
  237. throw err
  238. }
  239. })().then(settle.resolve, settle.reject)
  240. try {
  241. return await promise
  242. } finally {
  243. opening().delete(key)
  244. stages().delete(key)
  245. }
  246. }
  247. async function pull(
  248. opened: OpenedTarget,
  249. target: RunTarget,
  250. organizationId: string,
  251. snapshotId: string,
  252. key: string,
  253. dir: string
  254. ): Promise<RestorePointSummary> {
  255. stages().set(key, 'locating')
  256. const matches = await resticFind(opened.base, { snapshotId, pattern: 'data.json' })
  257. const staged = matches.filter((m) => isStagedDataFile(m.path))
  258. const dataPath = (staged.find((m) => m.path.split('/').at(-2) === organizationId) ?? staged[0])
  259. ?.path
  260. if (!dataPath) throw new Error('This restore point holds no data file')
  261. const stagedUnder = dataPath.split('/').at(-2) ?? ''
  262. const sourceOrganizationId = ORGANIZATION_ID.test(stagedUnder) ? stagedUnder : organizationId
  263. const dataFile = path.join(dir, 'data.json')
  264. let raw: unknown
  265. try {
  266. stages().set(key, 'pulling')
  267. await resticDumpToFile(opened.base, {
  268. snapshotId,
  269. path: dataPath,
  270. target: dataFile,
  271. maxBytes: maxDataFileBytes(),
  272. })
  273. stages().set(key, 'reading')
  274. try {
  275. raw = await readJsonFile<unknown>(dataFile)
  276. } catch {
  277. throw new Error('The data file in this restore point could not be read')
  278. }
  279. } finally {
  280. // Every row of the workshop, and for a platform backup the secret keys:
  281. // kept parsed in memory only, never left on disk.
  282. await rm(dataFile, { force: true })
  283. }
  284. const parsed = backupShape.safeParse(raw)
  285. if (!parsed.success) throw new Error('The data file in this restore point could not be read')
  286. stages().set(key, 'indexing')
  287. const data = parsed.data.data
  288. const vehicles = rowsOf(data.vehicles)
  289. // Work orders live inside their vehicle in the file (and counter sales on
  290. // their own); the explorer shows them as one list.
  291. const serviceRecords: Row[] = []
  292. for (const v of vehicles) {
  293. for (const sr of rowsOf(v.serviceRecords)) {
  294. serviceRecords.push({ ...sr, vehicleId: v.id, _vehicle: vehicleLabel(v) })
  295. }
  296. }
  297. for (const sr of rowsOf(data.counterSales)) serviceRecords.push({ ...sr, vehicleId: null })
  298. const rows: OpenPoint['rows'] = {
  299. customers: rowsOf(data.customers),
  300. vehicles,
  301. serviceRecords,
  302. quotes: rowsOf(data.quotes),
  303. inventoryParts: rowsOf(data.inventoryParts),
  304. }
  305. const point: OpenPoint = {
  306. key,
  307. organizationId,
  308. sourceOrganizationId,
  309. target,
  310. snapshotId,
  311. openedAt: Date.now(),
  312. dir,
  313. exportedAt: parsed.data.exportedAt ?? null,
  314. version: parsed.data.version,
  315. rows,
  316. byId: {
  317. customers: index(rows.customers),
  318. vehicles: index(rows.vehicles),
  319. serviceRecords: index(rows.serviceRecords),
  320. quotes: index(rows.quotes),
  321. inventoryParts: index(rows.inventoryParts),
  322. },
  323. }
  324. open().set(key, point)
  325. return summaryOf(point)
  326. }
  327. export interface RestorePointSummary {
  328. snapshotId: string
  329. exportedAt: string | null
  330. counts: Record<ExploreDataset, number>
  331. }
  332. function summaryOf(point: OpenPoint): RestorePointSummary {
  333. const counts = {} as Record<ExploreDataset, number>
  334. for (const d of EXPLORE_DATASETS) counts[d] = point.rows[d].length
  335. return { snapshotId: point.snapshotId, exportedAt: point.exportedAt, counts }
  336. }
  337. function getOpen(organizationId: string, snapshotId: string): OpenPoint {
  338. const point = open().get(keyOf(organizationId, snapshotId))
  339. if (!point || Date.now() - point.openedAt > KEEP_OPEN_MS) {
  340. throw new RestorePointClosedError()
  341. }
  342. point.openedAt = Date.now()
  343. return point
  344. }
  345. /** The restore point was never opened here, or has been closed again: open it first. */
  346. export class RestorePointClosedError extends Error {
  347. constructor() {
  348. super('restorePointClosed')
  349. this.name = 'RestorePointClosedError'
  350. }
  351. }
  352. function text(v: unknown): string {
  353. return typeof v === 'string' ? v : typeof v === 'number' ? String(v) : ''
  354. }
  355. function vehicleLabel(v: Row): string {
  356. return [
  357. text(v.licensePlate),
  358. [text(v.year), text(v.make), text(v.model)].filter(Boolean).join(' '),
  359. ]
  360. .filter(Boolean)
  361. .join(' · ')
  362. }
  363. /** One row as the list shows it: a title, a line under it, and the fields searched. */
  364. export interface ExploreListItem {
  365. id: string
  366. title: string
  367. subtitle: string
  368. /** ISO date the row is sorted by, newest first. */
  369. at: string | null
  370. }
  371. function listItem(dataset: ExploreDataset, row: Row, point: OpenPoint): ExploreListItem {
  372. const id = text(row.id)
  373. switch (dataset) {
  374. case 'customers':
  375. return {
  376. id,
  377. title: text(row.name) || text(row.company) || id,
  378. subtitle: [
  379. text(row.customerNumber) && `#${text(row.customerNumber)}`,
  380. text(row.company),
  381. text(row.email),
  382. text(row.phone),
  383. ]
  384. .filter(Boolean)
  385. .join(' · '),
  386. at: text(row.createdAt) || null,
  387. }
  388. case 'vehicles': {
  389. const customer =
  390. typeof row.customerId === 'string' ? point.byId.customers.get(row.customerId) : undefined
  391. return {
  392. id,
  393. title: vehicleLabel(row) || id,
  394. subtitle: customer ? listItem('customers', customer, point).title : text(row.vin),
  395. at: text(row.createdAt) || null,
  396. }
  397. }
  398. case 'serviceRecords':
  399. return {
  400. id,
  401. title: [text(row.invoiceNumber) && `#${text(row.invoiceNumber)}`, text(row.title)]
  402. .filter(Boolean)
  403. .join(' '),
  404. subtitle: [text(row._vehicle), text(row.status), text(row.serviceDate).slice(0, 10)]
  405. .filter(Boolean)
  406. .join(' · '),
  407. at: text(row.serviceDate) || text(row.createdAt) || null,
  408. }
  409. case 'quotes': {
  410. const customer =
  411. typeof row.customerId === 'string' ? point.byId.customers.get(row.customerId) : undefined
  412. return {
  413. id,
  414. title: [text(row.quoteNumber) && `#${text(row.quoteNumber)}`, text(row.title)]
  415. .filter(Boolean)
  416. .join(' '),
  417. subtitle: [customer ? listItem('customers', customer, point).title : '', text(row.status)]
  418. .filter(Boolean)
  419. .join(' · '),
  420. at: text(row.createdAt) || null,
  421. }
  422. }
  423. case 'inventoryParts':
  424. return {
  425. id,
  426. title: text(row.name) || id,
  427. subtitle: [text(row.partNumber), text(row.sku)].filter(Boolean).join(' · '),
  428. at: text(row.createdAt) || null,
  429. }
  430. }
  431. }
  432. function matches(item: ExploreListItem, row: Row, query: string): boolean {
  433. const q = query.toLowerCase()
  434. if (item.title.toLowerCase().includes(q) || item.subtitle.toLowerCase().includes(q)) return true
  435. for (const key of [
  436. 'vin',
  437. 'licensePlate',
  438. 'email',
  439. 'phone',
  440. 'partNumber',
  441. 'sku',
  442. 'invoiceNumber',
  443. 'quoteNumber',
  444. 'customerNumber',
  445. 'company',
  446. ]) {
  447. if (text(row[key]).toLowerCase().includes(q)) return true
  448. }
  449. return false
  450. }
  451. const PAGE = 50
  452. /** A page of one dataset, newest first, filtered by a free-text query. */
  453. export function listRestorePointRows(
  454. organizationId: string,
  455. snapshotId: string,
  456. dataset: ExploreDataset,
  457. query: string,
  458. page: number
  459. ): { items: ExploreListItem[]; total: number; page: number; pages: number } {
  460. const point = getOpen(organizationId, snapshotId)
  461. const q = query.trim().slice(0, 100)
  462. const all = point.rows[dataset]
  463. .map((row) => ({ row, item: listItem(dataset, row, point) }))
  464. .filter(({ row, item }) => !q || matches(item, row, q))
  465. .sort((a, b) => (Date.parse(b.item.at ?? '') || 0) - (Date.parse(a.item.at ?? '') || 0))
  466. const pages = Math.max(1, Math.ceil(all.length / PAGE))
  467. const p = Math.min(Math.max(1, page), pages)
  468. return {
  469. items: all.slice((p - 1) * PAGE, p * PAGE).map(({ item }) => item),
  470. total: all.length,
  471. page: p,
  472. pages,
  473. }
  474. }
  475. /** Keys that mean nothing to a reader, or are ours. */
  476. const HIDDEN_KEYS = new Set(['organizationId', 'userId', '_vehicle'])
  477. /**
  478. * One row with its nested rows, as the record page shows it: the file's own
  479. * shape, minus the bookkeeping columns. For a work order that is the parts,
  480. * labour, concerns, attachments, payments and so on it carries.
  481. */
  482. export function getRestorePointRow(
  483. organizationId: string,
  484. snapshotId: string,
  485. dataset: ExploreDataset,
  486. id: string
  487. ): { item: ExploreListItem; row: Row } | null {
  488. const point = getOpen(organizationId, snapshotId)
  489. const row = point.byId[dataset].get(id)
  490. if (!row) return null
  491. const shown: Row = {}
  492. for (const [k, v] of Object.entries(row)) if (!HIDDEN_KEYS.has(k)) shown[k] = v
  493. return { item: listItem(dataset, row, point), row: shown }
  494. }
  495. /**
  496. * One upload out of the restore point, written to a file under the restore
  497. * point's folder and returned for streaming. Looked for only under this
  498. * workshop's uploads folder in the snapshot, in a known category; this
  499. * workshop's own id first, then the id the snapshot was taken under, for a
  500. * repository an earlier install of the workshop wrote.
  501. */
  502. export async function extractRestorePointFile(
  503. organizationId: string,
  504. snapshotId: string,
  505. category: string,
  506. name: string
  507. ): Promise<{ file: string; bytes: number }> {
  508. const point = getOpen(organizationId, snapshotId)
  509. if (!UPLOAD_CATEGORIES.includes(category)) throw new Error('Unknown file category')
  510. if (!FILE_NAME.test(name) || name.includes('..')) throw new Error('Invalid file name')
  511. const file = path.join(point.dir, 'files', category, name)
  512. const have = await stat(file).catch(() => null)
  513. if (have?.isFile()) return { file, bytes: have.size }
  514. const opened = await openTarget(point.target)
  515. const found = await resticFind(opened.base, { snapshotId, pattern: name })
  516. const under = (owner: string) => (m: ResticFindMatch) => {
  517. const parts = m.path.split('/')
  518. return (
  519. parts.at(-1) === name &&
  520. parts.at(-2) === category &&
  521. parts.at(-3) === owner &&
  522. parts.at(-4) === 'uploads'
  523. )
  524. }
  525. const inUploads =
  526. found.find(under(organizationId)) ?? found.find(under(point.sourceOrganizationId))
  527. if (!inUploads) throw new Error('That file is not in this restore point')
  528. const { bytes } = await resticDumpToFile(opened.base, {
  529. snapshotId,
  530. path: inUploads.path,
  531. target: file,
  532. maxBytes: MAX_FILE_BYTES,
  533. })
  534. return { file, bytes }
  535. }