@conduction/nextcloud-vue 2.77.0 → 2.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundled-packages.json +0 -2
- package/dist/esm/bundled-packages.json +0 -2
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +113 -104
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
- package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue2.js +23 -0
- package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue2.js.map +1 -1
- package/dist/esm/components/CnAiCompanion/CnAiCompanion.vue3.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +12 -2
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnBoardView/CnBoardView.vue2.js +2 -1
- package/dist/esm/components/CnBoardView/CnBoardView.vue2.js.map +1 -1
- package/dist/esm/components/CnBoardView/CnBoardView.vue3.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +117 -0
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +122 -97
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js +37 -0
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js +2 -2
- package/dist/esm/components/CnChartWidget/CnChartWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +168 -5
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +140 -92
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +84 -2
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +4 -2
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue.js +1 -1
- package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue2.js +45 -2
- package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue2.js.map +1 -1
- package/dist/esm/components/CnDateRangePicker/CnDateRangePicker.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +61 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +173 -5
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +181 -99
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/filesBrowser.js +29 -11
- package/dist/esm/components/CnFilesBrowser/filesBrowser.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/openRegisterSource.js +252 -0
- package/dist/esm/components/CnFilesBrowser/openRegisterSource.js.map +1 -0
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +8 -2
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnFormPage/CnFormPage.vue2.js +4 -1
- package/dist/esm/components/CnFormPage/CnFormPage.vue2.js.map +1 -1
- package/dist/esm/components/CnFormPage/CnFormPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +31 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +3 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +100 -3
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +4 -2
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js +3 -2
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue2.js.map +1 -1
- package/dist/esm/components/CnQuickFilterBar/CnQuickFilterBar.vue3.js.map +1 -1
- package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue2.js.map +1 -1
- package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js +1 -1
- package/dist/esm/components/CnSavedViewsControl/CnSavedViewsControl.vue3.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +36 -0
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +35 -23
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue2.js +6 -2
- package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue2.js.map +1 -1
- package/dist/esm/components/CnStatusBadge/CnStatusBadge.vue3.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +30 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +13 -0
- package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
- package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
- package/dist/esm/composables/useAppManifest.js +8 -3
- package/dist/esm/composables/useAppManifest.js.map +1 -1
- package/dist/esm/composables/useFormDraft.js +11 -1
- package/dist/esm/composables/useFormDraft.js.map +1 -1
- package/dist/esm/composables/useObjectPresence.js +15 -4
- package/dist/esm/composables/useObjectPresence.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +68 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +113 -2
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/nextcloud-vue.css +526 -16
- package/dist/esm/utils/badgeVariants.js +32 -0
- package/dist/esm/utils/badgeVariants.js.map +1 -0
- package/dist/esm/utils/boardDate.js +193 -0
- package/dist/esm/utils/boardDate.js.map +1 -0
- package/dist/esm/utils/dynamicProperties.js +5 -1
- package/dist/esm/utils/dynamicProperties.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +57 -6
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +2569 -811
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +526 -16
- package/l10n/en.json +68 -1
- package/l10n/nl.json +113 -2
- package/package.json +1 -1
- package/scripts/build-validators.js +26 -0
- package/src/components/CnActionsBar/CnActionsBar.vue +48 -39
- package/src/components/CnAiCompanion/CnAiCompanion.vue +23 -0
- package/src/components/CnAppRoot/CnAppRoot.vue +12 -2
- package/src/components/CnBoardView/CnBoardView.vue +2 -1
- package/src/components/CnCellRenderer/CnCellRenderer.md +1 -0
- package/src/components/CnCellRenderer/CnCellRenderer.vue +169 -10
- package/src/components/CnChartWidget/CnChartWidget.vue +39 -2
- package/src/components/CnDashboardPage/CnDashboardPage.vue +248 -8
- package/src/components/CnDataTable/CnDataTable.md +2 -0
- package/src/components/CnDataTable/CnDataTable.vue +88 -4
- package/src/components/CnDateRangePicker/CnDateRangePicker.vue +44 -1
- package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +61 -1
- package/src/components/CnFilesBrowser/CnFilesBrowser.vue +232 -11
- package/src/components/CnFilesBrowser/filesBrowser.js +43 -2
- package/src/components/CnFilesBrowser/index.js +1 -0
- package/src/components/CnFilesBrowser/openRegisterSource.js +248 -0
- package/src/components/CnFormDialog/CnFormDialog.vue +8 -2
- package/src/components/CnFormPage/CnFormPage.vue +4 -1
- package/src/components/CnIndexPage/CnIndexPage.vue +33 -3
- package/src/components/CnObjectSidebar/CnFilesTab.vue +103 -4
- package/src/components/CnQuickFilterBar/CnQuickFilterBar.vue +3 -2
- package/src/components/CnSavedViewsControl/CnSavedViewsControl.vue +1 -1
- package/src/components/CnStatWidget/CnStatWidget.vue +52 -2
- package/src/components/CnStatusBadge/CnStatusBadge.vue +6 -2
- package/src/components/CnTabsWidget/CnTabsWidget.vue +30 -1
- package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +13 -0
- package/src/components/index.js +1 -1
- package/src/composables/__tests__/useObjectPresence.spec.js +24 -0
- package/src/composables/useAppManifest.js +8 -3
- package/src/composables/useFormDraft.js +11 -1
- package/src/composables/useObjectPresence.js +15 -4
- package/src/css/badge.css +31 -0
- package/src/css/kpi-card.css +51 -0
- package/src/css/look-board-detail.css +10 -0
- package/src/css/look-board-index.css +128 -14
- package/src/css/look-board.css +85 -0
- package/src/css/table.css +37 -0
- package/src/index.js +4 -0
- package/src/schemas/app-manifest-v2.schema.json +50 -5
- package/src/utils/badgeVariants.js +29 -0
- package/src/utils/boardDate.js +189 -0
- package/src/utils/dynamicProperties.js +5 -1
- package/src/utils/validateManifestV2.compiled.js +57 -2
- package/dist/esm/_virtual/equal.js +0 -4
- package/dist/esm/_virtual/equal.js.map +0 -1
- package/dist/esm/_virtual/ucs2length.js +0 -4
- package/dist/esm/_virtual/ucs2length.js.map +0 -1
- package/dist/esm/node_modules/ajv/dist/runtime/equal.js +0 -9
- package/dist/esm/node_modules/ajv/dist/runtime/equal.js.map +0 -1
- package/dist/esm/node_modules/ajv/dist/runtime/ucs2length.js +0 -25
- package/dist/esm/node_modules/ajv/dist/runtime/ucs2length.js.map +0 -1
- package/dist/esm/node_modules/fast-deep-equal/index.js +0 -47
- package/dist/esm/node_modules/fast-deep-equal/index.js.map +0 -1
|
@@ -128,12 +128,22 @@ function readDraft(key, { objectUpdated = '', now = Date.now() } = {}) {
|
|
|
128
128
|
* What the draft indicator says: "Saving" while a local write is pending,
|
|
129
129
|
* "Saved just now" / "Saved 3 minutes ago" once it landed, nothing before that.
|
|
130
130
|
*
|
|
131
|
+
* After a failed save (`options.saveFailed`) it never says Saving or Saved:
|
|
132
|
+
* next to a server error those words read as if the record had been saved.
|
|
133
|
+
* It then says "Draft kept on this device" when a local copy is stored, and
|
|
134
|
+
* nothing otherwise.
|
|
135
|
+
*
|
|
131
136
|
* @param {string} state `idle`, `saving` or `saved`.
|
|
132
137
|
* @param {number} [savedAt] When the last write landed, in epoch ms.
|
|
133
138
|
* @param {number} [now] The current epoch ms, for testing.
|
|
139
|
+
* @param {{saveFailed?: boolean}} [options] `saveFailed`: the form shows a failed save.
|
|
134
140
|
* @return {string} The text, or '' when it says nothing.
|
|
141
|
+
* @spec openspec/changes/cell-labels-and-draft-indicator/specs/cell-labels-and-draft-indicator/spec.md#requirement-the-draft-indicator-never-reads-as-a-server-save-after-a-failed-save
|
|
135
142
|
*/
|
|
136
|
-
function draftIndicatorText(state, savedAt = 0, now = Date.now()) {
|
|
143
|
+
function draftIndicatorText(state, savedAt = 0, now = Date.now(), options = {}) {
|
|
144
|
+
if (options && options.saveFailed === true) {
|
|
145
|
+
return state === 'saved' ? translate('nextcloud-vue', 'Draft kept on this device') : ''
|
|
146
|
+
}
|
|
137
147
|
if (state === 'saving') {
|
|
138
148
|
return translate('nextcloud-vue', 'Saving')
|
|
139
149
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useFormDraft.js","sources":["../../../src/composables/useFormDraft.js"],"sourcesContent":["/**\n * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>\n * SPDX-License-Identifier: AGPL-3.0-or-later\n *\n * useFormDraft — keep what somebody typed when the dialog closes on them.\n *\n * A handler filling a 27-field case type form and losing the tab starts over.\n * This writes the values to `localStorage` as they type and offers them back\n * the next time the same form opens.\n *\n * 🔴 IT NEVER DECIDES TO RESTORE. The draft is offered and the person chooses.\n * Silently refilling a form is how somebody submits last week's answers without\n * noticing they were there, and a form that fills itself is indistinguishable\n * from a form the server prefilled.\n *\n * 🔴 EVERY READ AND WRITE IS WRAPPED. `localStorage` throws in a private\n * window, with site data blocked, and inside some embedded webviews. A draft\n * that cannot be saved is a small loss; a dialog that will not open because\n * saving the draft threw is a large one.\n *\n * WHAT IT DELIBERATELY DOES NOT DO. It does not touch the server. A keystroke\n * autosave against the object API writes an audit row for every half-typed\n * value, and the audit trail is read by people answering \"who changed this\".\n */\n\nimport { translate as t } from '@nextcloud/l10n'\n\n/** How long a draft stays interesting, in milliseconds (7 days). */\nexport const DRAFT_MAX_AGE_MS = (7 * 24 * 60 * 60 * 1000)\n\n/** How long to wait after the last keystroke before writing, in milliseconds. */\nexport const DRAFT_DEBOUNCE_MS = 500\n\n/** The prefix every draft key carries, so a sweep can find them. */\nexport const DRAFT_KEY_PREFIX = 'cn-form-draft'\n\n/**\n * The storage key one form's draft lives under.\n *\n * 🔴 THE USER ID IS PART OF THE KEY. A shared browser profile at a service\n * desk is the ordinary case in a municipality, and a draft keyed without the\n * user hands the next person at the counter what the last one typed.\n *\n * @param {object} parts The identifying parts.\n * @param {string} [parts.appId] The app the form belongs to.\n * @param {string} [parts.schema] The schema being filled in.\n * @param {string} [parts.objectId] The object being edited, or '' when new.\n * @param {string} [parts.userId] Who is typing.\n * @return {string} The storage key.\n */\nexport function draftKey({ appId = '', schema = '', objectId = '', userId = '' } = {}) {\n\treturn [\n\t\tDRAFT_KEY_PREFIX,\n\t\tString(appId || 'app'),\n\t\tString(schema || 'schema'),\n\t\tString(objectId || 'new'),\n\t\tString(userId || 'anonymous'),\n\t].join(':')\n}\n\n/**\n * Read one draft back, or null when there is nothing usable.\n *\n * Returns null for an absent key, unreadable storage, unparseable JSON, a\n * draft older than {@link DRAFT_MAX_AGE_MS}, and a draft older than the object\n * it belongs to. That last one matters: a colleague who saved the record after\n * you started typing has said something more recent than your draft, and\n * offering yours on top would invite you to overwrite them without being told.\n *\n * @param {string} key The storage key.\n * @param {object} [options] Reading options.\n * @param {string} [options.objectUpdated] The object's `updated` timestamp, when it has one.\n * @param {number} [options.now] The current epoch ms, for testing.\n * @return {{values: object, savedAt: number}|null} The draft, or null.\n */\nexport function readDraft(key, { objectUpdated = '', now = Date.now() } = {}) {\n\tlet raw\n\ttry {\n\t\traw = window.localStorage.getItem(key)\n\t} catch {\n\t\t// Blocked or unavailable storage reads as \"no draft\", never as a throw.\n\t\treturn null\n\t}\n\n\tif (!raw) {\n\t\treturn null\n\t}\n\n\tlet parsed\n\ttry {\n\t\tparsed = JSON.parse(raw)\n\t} catch {\n\t\t// A corrupted entry is dropped rather than offered. Nothing here can\n\t\t// repair it, and offering half a form is worse than offering none.\n\t\tclearDraft(key)\n\t\treturn null\n\t}\n\n\tif (!parsed || typeof parsed !== 'object' || typeof parsed.savedAt !== 'number') {\n\t\tclearDraft(key)\n\t\treturn null\n\t}\n\n\tif ((now - parsed.savedAt) > DRAFT_MAX_AGE_MS) {\n\t\tclearDraft(key)\n\t\treturn null\n\t}\n\n\tif (objectUpdated) {\n\t\tconst updatedAt = Date.parse(String(objectUpdated))\n\t\tif (!Number.isNaN(updatedAt) && updatedAt > parsed.savedAt) {\n\t\t\t// The record moved on after this draft was written.\n\t\t\tclearDraft(key)\n\t\t\treturn null\n\t\t}\n\t}\n\n\tif (!parsed.values || typeof parsed.values !== 'object') {\n\t\tclearDraft(key)\n\t\treturn null\n\t}\n\n\treturn { values: parsed.values, savedAt: parsed.savedAt }\n}\n\n/**\n * What the draft indicator says: \"Saving\" while a local write is pending,\n * \"Saved just now\" / \"Saved 3 minutes ago\" once it landed, nothing before that.\n *\n * @param {string} state `idle`, `saving` or `saved`.\n * @param {number} [savedAt] When the last write landed, in epoch ms.\n * @param {number} [now] The current epoch ms, for testing.\n * @return {string} The text, or '' when it says nothing.\n */\nexport function draftIndicatorText(state, savedAt = 0, now = Date.now()) {\n\tif (state === 'saving') {\n\t\treturn t('nextcloud-vue', 'Saving')\n\t}\n\tif (state !== 'saved') {\n\t\treturn ''\n\t}\n\tconst minutes = Math.floor(Math.max(0, now - savedAt) / 60000)\n\tif (!savedAt || minutes < 1) {\n\t\treturn t('nextcloud-vue', 'Saved just now')\n\t}\n\ttry {\n\t\tconst relative = new Intl.RelativeTimeFormat(document.documentElement.lang || 'en', { numeric: 'auto' }).format(-minutes, 'minute')\n\t\treturn t('nextcloud-vue', 'Saved {time}', { time: relative })\n\t} catch {\n\t\treturn t('nextcloud-vue', 'Saved')\n\t}\n}\n\n/**\n * Write one draft.\n *\n * @param {string} key The storage key.\n * @param {object} values The form values.\n * @param {number} [now] The current epoch ms, for testing.\n * @return {boolean} True when it was stored.\n */\nexport function writeDraft(key, values, now = Date.now()) {\n\ttry {\n\t\twindow.localStorage.setItem(\n\t\t\tkey,\n\t\t\tJSON.stringify({ savedAt: now, values: (values || {}) }),\n\t\t)\n\t\treturn true\n\t} catch {\n\t\t// Quota exceeded, or storage blocked. The form keeps working; the\n\t\t// recovery does not exist for this session. Reported to the caller so\n\t\t// the indicator can stay quiet rather than claim \"Saved\".\n\t\treturn false\n\t}\n}\n\n/**\n * Forget one draft.\n *\n * @param {string} key The storage key.\n * @return {void}\n */\nexport function clearDraft(key) {\n\ttry {\n\t\twindow.localStorage.removeItem(key)\n\t} catch {\n\t\t// Nothing to do and nothing worth failing a dialog over.\n\t}\n}\n\n/**\n * The Options API mixin the library's components use.\n *\n * A mixin rather than a Vue 3 composable, because these components are Options\n * API and the library builds for both Vue 2.7 and Vue 3.\n *\n * @return {object} The mixin.\n */\nexport function formDraftMixin() {\n\treturn {\n\t\tdata() {\n\t\t\treturn {\n\t\t\t\t/** The draft offered on open, or null when there is none. */\n\t\t\t\tdraftOffer: null,\n\t\t\t\t/** 'idle' | 'saving' | 'saved' — what the indicator announces. */\n\t\t\t\tdraftState: 'idle',\n\t\t\t\t/** When the last local write landed, in epoch ms. */\n\t\t\t\tdraftSavedAt: 0,\n\t\t\t\t/** Non-reactive handle for the debounce. */\n\t\t\t\tdraftTimeout: null,\n\t\t\t\t/**\n\t\t\t\t * Whether the form's own initial population has happened.\n\t\t\t\t *\n\t\t\t\t * The first change to the values is the component building\n\t\t\t\t * them, not a person typing. See the watcher that reads this.\n\t\t\t\t */\n\t\t\t\tdraftSeeded: false,\n\t\t\t}\n\t\t},\n\n\t\tbeforeUnmount() {\n\t\t\tif (this.draftTimeout) {\n\t\t\t\tclearTimeout(this.draftTimeout)\n\t\t\t\tthis.draftTimeout = null\n\t\t\t}\n\t\t},\n\n\t\tmethods: {\n\t\t\t/**\n\t\t\t * Schedule a draft write, debounced.\n\t\t\t *\n\t\t\t * @param {string} key The storage key.\n\t\t\t * @param {object} values The current form values.\n\t\t\t * @return {void}\n\t\t\t */\n\t\t\tscheduleDraftWrite(key, values) {\n\t\t\t\tif (!key) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tthis.draftState = 'saving'\n\t\t\t\tif (this.draftTimeout) {\n\t\t\t\t\tclearTimeout(this.draftTimeout)\n\t\t\t\t}\n\n\t\t\t\tthis.draftTimeout = setTimeout(() => {\n\t\t\t\t\tthis.draftTimeout = null\n\t\t\t\t\t// 'idle' and not 'saved' when the write failed: an\n\t\t\t\t\t// indicator that says Saved about a draft that is not\n\t\t\t\t\t// stored is the whole class of defect this library has\n\t\t\t\t\t// been paying down, in the one place a user is trusting it.\n\t\t\t\t\tconst stored = writeDraft(key, values)\n\t\t\t\t\tthis.draftState = stored ? 'saved' : 'idle'\n\t\t\t\t\tthis.draftSavedAt = stored ? Date.now() : 0\n\t\t\t\t}, DRAFT_DEBOUNCE_MS)\n\t\t\t},\n\n\t\t\t/**\n\t\t\t * Drop the draft and stop announcing anything about it.\n\t\t\t *\n\t\t\t * @param {string} key The storage key.\n\t\t\t * @return {void}\n\t\t\t */\n\t\t\tforgetDraft(key) {\n\t\t\t\tif (this.draftTimeout) {\n\t\t\t\t\tclearTimeout(this.draftTimeout)\n\t\t\t\t\tthis.draftTimeout = null\n\t\t\t\t}\n\n\t\t\t\tclearDraft(key)\n\t\t\t\tthis.draftOffer = null\n\t\t\t\tthis.draftState = 'idle'\n\t\t\t},\n\t\t},\n\t}\n}\n"],"names":["t"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"useFormDraft.js","sources":["../../../src/composables/useFormDraft.js"],"sourcesContent":["/**\n * SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>\n * SPDX-License-Identifier: AGPL-3.0-or-later\n *\n * useFormDraft — keep what somebody typed when the dialog closes on them.\n *\n * A handler filling a 27-field case type form and losing the tab starts over.\n * This writes the values to `localStorage` as they type and offers them back\n * the next time the same form opens.\n *\n * 🔴 IT NEVER DECIDES TO RESTORE. The draft is offered and the person chooses.\n * Silently refilling a form is how somebody submits last week's answers without\n * noticing they were there, and a form that fills itself is indistinguishable\n * from a form the server prefilled.\n *\n * 🔴 EVERY READ AND WRITE IS WRAPPED. `localStorage` throws in a private\n * window, with site data blocked, and inside some embedded webviews. A draft\n * that cannot be saved is a small loss; a dialog that will not open because\n * saving the draft threw is a large one.\n *\n * WHAT IT DELIBERATELY DOES NOT DO. It does not touch the server. A keystroke\n * autosave against the object API writes an audit row for every half-typed\n * value, and the audit trail is read by people answering \"who changed this\".\n */\n\nimport { translate as t } from '@nextcloud/l10n'\n\n/** How long a draft stays interesting, in milliseconds (7 days). */\nexport const DRAFT_MAX_AGE_MS = (7 * 24 * 60 * 60 * 1000)\n\n/** How long to wait after the last keystroke before writing, in milliseconds. */\nexport const DRAFT_DEBOUNCE_MS = 500\n\n/** The prefix every draft key carries, so a sweep can find them. */\nexport const DRAFT_KEY_PREFIX = 'cn-form-draft'\n\n/**\n * The storage key one form's draft lives under.\n *\n * 🔴 THE USER ID IS PART OF THE KEY. A shared browser profile at a service\n * desk is the ordinary case in a municipality, and a draft keyed without the\n * user hands the next person at the counter what the last one typed.\n *\n * @param {object} parts The identifying parts.\n * @param {string} [parts.appId] The app the form belongs to.\n * @param {string} [parts.schema] The schema being filled in.\n * @param {string} [parts.objectId] The object being edited, or '' when new.\n * @param {string} [parts.userId] Who is typing.\n * @return {string} The storage key.\n */\nexport function draftKey({ appId = '', schema = '', objectId = '', userId = '' } = {}) {\n\treturn [\n\t\tDRAFT_KEY_PREFIX,\n\t\tString(appId || 'app'),\n\t\tString(schema || 'schema'),\n\t\tString(objectId || 'new'),\n\t\tString(userId || 'anonymous'),\n\t].join(':')\n}\n\n/**\n * Read one draft back, or null when there is nothing usable.\n *\n * Returns null for an absent key, unreadable storage, unparseable JSON, a\n * draft older than {@link DRAFT_MAX_AGE_MS}, and a draft older than the object\n * it belongs to. That last one matters: a colleague who saved the record after\n * you started typing has said something more recent than your draft, and\n * offering yours on top would invite you to overwrite them without being told.\n *\n * @param {string} key The storage key.\n * @param {object} [options] Reading options.\n * @param {string} [options.objectUpdated] The object's `updated` timestamp, when it has one.\n * @param {number} [options.now] The current epoch ms, for testing.\n * @return {{values: object, savedAt: number}|null} The draft, or null.\n */\nexport function readDraft(key, { objectUpdated = '', now = Date.now() } = {}) {\n\tlet raw\n\ttry {\n\t\traw = window.localStorage.getItem(key)\n\t} catch {\n\t\t// Blocked or unavailable storage reads as \"no draft\", never as a throw.\n\t\treturn null\n\t}\n\n\tif (!raw) {\n\t\treturn null\n\t}\n\n\tlet parsed\n\ttry {\n\t\tparsed = JSON.parse(raw)\n\t} catch {\n\t\t// A corrupted entry is dropped rather than offered. Nothing here can\n\t\t// repair it, and offering half a form is worse than offering none.\n\t\tclearDraft(key)\n\t\treturn null\n\t}\n\n\tif (!parsed || typeof parsed !== 'object' || typeof parsed.savedAt !== 'number') {\n\t\tclearDraft(key)\n\t\treturn null\n\t}\n\n\tif ((now - parsed.savedAt) > DRAFT_MAX_AGE_MS) {\n\t\tclearDraft(key)\n\t\treturn null\n\t}\n\n\tif (objectUpdated) {\n\t\tconst updatedAt = Date.parse(String(objectUpdated))\n\t\tif (!Number.isNaN(updatedAt) && updatedAt > parsed.savedAt) {\n\t\t\t// The record moved on after this draft was written.\n\t\t\tclearDraft(key)\n\t\t\treturn null\n\t\t}\n\t}\n\n\tif (!parsed.values || typeof parsed.values !== 'object') {\n\t\tclearDraft(key)\n\t\treturn null\n\t}\n\n\treturn { values: parsed.values, savedAt: parsed.savedAt }\n}\n\n/**\n * What the draft indicator says: \"Saving\" while a local write is pending,\n * \"Saved just now\" / \"Saved 3 minutes ago\" once it landed, nothing before that.\n *\n * After a failed save (`options.saveFailed`) it never says Saving or Saved:\n * next to a server error those words read as if the record had been saved.\n * It then says \"Draft kept on this device\" when a local copy is stored, and\n * nothing otherwise.\n *\n * @param {string} state `idle`, `saving` or `saved`.\n * @param {number} [savedAt] When the last write landed, in epoch ms.\n * @param {number} [now] The current epoch ms, for testing.\n * @param {{saveFailed?: boolean}} [options] `saveFailed`: the form shows a failed save.\n * @return {string} The text, or '' when it says nothing.\n * @spec openspec/changes/cell-labels-and-draft-indicator/specs/cell-labels-and-draft-indicator/spec.md#requirement-the-draft-indicator-never-reads-as-a-server-save-after-a-failed-save\n */\nexport function draftIndicatorText(state, savedAt = 0, now = Date.now(), options = {}) {\n\tif (options && options.saveFailed === true) {\n\t\treturn state === 'saved' ? t('nextcloud-vue', 'Draft kept on this device') : ''\n\t}\n\tif (state === 'saving') {\n\t\treturn t('nextcloud-vue', 'Saving')\n\t}\n\tif (state !== 'saved') {\n\t\treturn ''\n\t}\n\tconst minutes = Math.floor(Math.max(0, now - savedAt) / 60000)\n\tif (!savedAt || minutes < 1) {\n\t\treturn t('nextcloud-vue', 'Saved just now')\n\t}\n\ttry {\n\t\tconst relative = new Intl.RelativeTimeFormat(document.documentElement.lang || 'en', { numeric: 'auto' }).format(-minutes, 'minute')\n\t\treturn t('nextcloud-vue', 'Saved {time}', { time: relative })\n\t} catch {\n\t\treturn t('nextcloud-vue', 'Saved')\n\t}\n}\n\n/**\n * Write one draft.\n *\n * @param {string} key The storage key.\n * @param {object} values The form values.\n * @param {number} [now] The current epoch ms, for testing.\n * @return {boolean} True when it was stored.\n */\nexport function writeDraft(key, values, now = Date.now()) {\n\ttry {\n\t\twindow.localStorage.setItem(\n\t\t\tkey,\n\t\t\tJSON.stringify({ savedAt: now, values: (values || {}) }),\n\t\t)\n\t\treturn true\n\t} catch {\n\t\t// Quota exceeded, or storage blocked. The form keeps working; the\n\t\t// recovery does not exist for this session. Reported to the caller so\n\t\t// the indicator can stay quiet rather than claim \"Saved\".\n\t\treturn false\n\t}\n}\n\n/**\n * Forget one draft.\n *\n * @param {string} key The storage key.\n * @return {void}\n */\nexport function clearDraft(key) {\n\ttry {\n\t\twindow.localStorage.removeItem(key)\n\t} catch {\n\t\t// Nothing to do and nothing worth failing a dialog over.\n\t}\n}\n\n/**\n * The Options API mixin the library's components use.\n *\n * A mixin rather than a Vue 3 composable, because these components are Options\n * API and the library builds for both Vue 2.7 and Vue 3.\n *\n * @return {object} The mixin.\n */\nexport function formDraftMixin() {\n\treturn {\n\t\tdata() {\n\t\t\treturn {\n\t\t\t\t/** The draft offered on open, or null when there is none. */\n\t\t\t\tdraftOffer: null,\n\t\t\t\t/** 'idle' | 'saving' | 'saved' — what the indicator announces. */\n\t\t\t\tdraftState: 'idle',\n\t\t\t\t/** When the last local write landed, in epoch ms. */\n\t\t\t\tdraftSavedAt: 0,\n\t\t\t\t/** Non-reactive handle for the debounce. */\n\t\t\t\tdraftTimeout: null,\n\t\t\t\t/**\n\t\t\t\t * Whether the form's own initial population has happened.\n\t\t\t\t *\n\t\t\t\t * The first change to the values is the component building\n\t\t\t\t * them, not a person typing. See the watcher that reads this.\n\t\t\t\t */\n\t\t\t\tdraftSeeded: false,\n\t\t\t}\n\t\t},\n\n\t\tbeforeUnmount() {\n\t\t\tif (this.draftTimeout) {\n\t\t\t\tclearTimeout(this.draftTimeout)\n\t\t\t\tthis.draftTimeout = null\n\t\t\t}\n\t\t},\n\n\t\tmethods: {\n\t\t\t/**\n\t\t\t * Schedule a draft write, debounced.\n\t\t\t *\n\t\t\t * @param {string} key The storage key.\n\t\t\t * @param {object} values The current form values.\n\t\t\t * @return {void}\n\t\t\t */\n\t\t\tscheduleDraftWrite(key, values) {\n\t\t\t\tif (!key) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\n\t\t\t\tthis.draftState = 'saving'\n\t\t\t\tif (this.draftTimeout) {\n\t\t\t\t\tclearTimeout(this.draftTimeout)\n\t\t\t\t}\n\n\t\t\t\tthis.draftTimeout = setTimeout(() => {\n\t\t\t\t\tthis.draftTimeout = null\n\t\t\t\t\t// 'idle' and not 'saved' when the write failed: an\n\t\t\t\t\t// indicator that says Saved about a draft that is not\n\t\t\t\t\t// stored is the whole class of defect this library has\n\t\t\t\t\t// been paying down, in the one place a user is trusting it.\n\t\t\t\t\tconst stored = writeDraft(key, values)\n\t\t\t\t\tthis.draftState = stored ? 'saved' : 'idle'\n\t\t\t\t\tthis.draftSavedAt = stored ? Date.now() : 0\n\t\t\t\t}, DRAFT_DEBOUNCE_MS)\n\t\t\t},\n\n\t\t\t/**\n\t\t\t * Drop the draft and stop announcing anything about it.\n\t\t\t *\n\t\t\t * @param {string} key The storage key.\n\t\t\t * @return {void}\n\t\t\t */\n\t\t\tforgetDraft(key) {\n\t\t\t\tif (this.draftTimeout) {\n\t\t\t\t\tclearTimeout(this.draftTimeout)\n\t\t\t\t\tthis.draftTimeout = null\n\t\t\t\t}\n\n\t\t\t\tclearDraft(key)\n\t\t\t\tthis.draftOffer = null\n\t\t\t\tthis.draftState = 'idle'\n\t\t\t},\n\t\t},\n\t}\n}\n"],"names":["t"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getCurrentUser } from '@nextcloud/auth';
|
|
1
|
+
import { getCurrentUser, getRequestToken } from '@nextcloud/auth';
|
|
2
2
|
import axios__default from '@nextcloud/axios';
|
|
3
3
|
import { generateUrl } from '@nextcloud/router';
|
|
4
4
|
import { ref, computed, watch, getCurrentScope, onScopeDispose, unref } from 'vue';
|
|
@@ -202,7 +202,15 @@ function useObjectPresence(register, schema, objectUuid, options = {}) {
|
|
|
202
202
|
* the browser and survives the navigation, which is the only mechanism
|
|
203
203
|
* that does.
|
|
204
204
|
*
|
|
205
|
+
* 🔴 THE TOKEN RIDES IN THE BODY. A beacon cannot set headers, so the
|
|
206
|
+
* `requesttoken` header axios adds is missing, and the departure route
|
|
207
|
+
* keeps Nextcloud's CSRF check. Nextcloud also reads the token from a
|
|
208
|
+
* POST field, so it goes in a form body. Without it the beacon answers
|
|
209
|
+
* 412; before OpenRegister registered POST on `/presence` it answered 405
|
|
210
|
+
* (seen on cloud.conduction.nl on every dossiq case page).
|
|
211
|
+
*
|
|
205
212
|
* @return {void}
|
|
213
|
+
* @spec openspec/changes/no-calls-to-missing-apps-and-presence-beacon/specs/object-presence/spec.md#requirement-a-closing-tab-departs-through-a-route-that-exists
|
|
206
214
|
*/
|
|
207
215
|
function beaconDepart() {
|
|
208
216
|
const url = presenceUrl();
|
|
@@ -210,9 +218,12 @@ function useObjectPresence(register, schema, objectUuid, options = {}) {
|
|
|
210
218
|
return
|
|
211
219
|
}
|
|
212
220
|
try {
|
|
213
|
-
// No DELETE from a beacon
|
|
214
|
-
//
|
|
215
|
-
|
|
221
|
+
// No DELETE from a beacon, it is always a POST, so the server's
|
|
222
|
+
// POST departure route is used, marked `_method=DELETE`. Both reach
|
|
223
|
+
// one implementation.
|
|
224
|
+
const body = new FormData();
|
|
225
|
+
body.append('requesttoken', getRequestToken() || '');
|
|
226
|
+
navigator.sendBeacon?.(url.replace(/\/presence$/, '/presence?_method=DELETE'), body);
|
|
216
227
|
} catch {
|
|
217
228
|
// Best effort.
|
|
218
229
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useObjectPresence.js","sources":["../../../src/composables/useObjectPresence.js"],"sourcesContent":["// SPDX-License-Identifier: EUPL-1.2\n// Copyright (C) 2026 Conduction B.V.\n\nimport { getCurrentUser } from '@nextcloud/auth'\nimport axios from '@nextcloud/axios'\nimport { generateUrl } from '@nextcloud/router'\nimport { computed, getCurrentScope, onScopeDispose, ref, unref, watch } from 'vue'\nimport { buildObjectKey } from '../store/liveUpdates/eventKeys.js'\nimport { getLiveUpdates } from '../store/liveUpdates/transport.js'\n\n/**\n * How long the server believes a reader after their last beat, in seconds.\n *\n * Mirrored from `PresenceService::WINDOW_SECONDS` and used ONLY as the\n * fallback pace when a server does not answer with its own. The server's\n * `beatSeconds` wins whenever it is present, because the window is its\n * decision and a client pacing itself to a stale copy of it drops off the\n * list while its user is still reading.\n */\nconst FALLBACK_BEAT_SECONDS = 30\n\n/**\n * useObjectPresence — who else has this object open.\n *\n * 🔴 A HEARTBEAT, NOT A CONNECTION. notify_push tells the server nothing about\n * who is looking at what: a socket can be open while the tab showing this\n * object closed ten minutes ago, and a socket can drop while the reader is\n * still there. So the client says \"still here\" on a timer and the server stops\n * believing it after its window. Missing two beats reads as gone, which\n * survives a lost socket where connection tracking does not.\n *\n * 🔑 THE LIST COMES FROM TWO PLACES, AND THAT IS DELIBERATE. Every beat answers\n * the current list, so the component is correct even on an instance with no\n * notify_push at all — it simply learns at beat pace. The push is the\n * OPTIMISATION: it carries the new list on the object's own channel the moment\n * somebody arrives or leaves. Relying on the push alone would make presence a\n * feature that silently does nothing wherever notify_push is not installed,\n * which is most development instances and some production ones.\n *\n * 🔑 DEPARTURE IS SENT TWICE, ON PURPOSE. `tryOnScopeDispose` covers a route\n * change and an unmount; `beforeunload` with `sendBeacon` covers a closed tab,\n * which fires no Vue hook at all. Without the beacon a closed tab lingers for a\n * whole window on everybody else's screen; without the dispose a route change\n * does. Both are cheap and the server is idempotent about it.\n *\n * @param {string|import('vue').Ref<string>} register OpenRegister register slug.\n * @param {string|import('vue').Ref<string>} schema OpenRegister schema slug.\n * @param {string|import('vue').Ref<string>} objectUuid The object being read.\n * @param {object} [options] Optional config.\n * @param {boolean|import('vue').Ref<boolean>|(() => boolean)} [options.enabled] Set false to make the\n * whole thing inert. A ref or getter is read on every use, so a consumer whose answer changes\n * after mount turns the subscription on when it flips.\n * @return {{\n * others: import('vue').ComputedRef<Array<object>>,\n * count: import('vue').ComputedRef<number>,\n * active: import('vue').Ref<boolean>,\n * depart: () => Promise<void>\n * }} The other readers, and a way to leave early.\n */\nexport function useObjectPresence(register, schema, objectUuid, options = {}) {\n\tconst present = ref([])\n\tconst active = ref(false)\n\tlet beatTimer = null\n\tlet subscription = null\n\tlet beatSeconds = FALLBACK_BEAT_SECONDS\n\n\t// A value, a ref or a getter, like the address parts below: a consumer whose\n\t// answer changes after mount (widgets that arrive late) has to be able to say so.\n\tconst enabled = () => unref(typeof options.enabled === 'function' ? options.enabled() : options.enabled) !== false\n\n\t/**\n\t * Read an address part that may be a value, a ref OR a getter.\n\t *\n\t * 🔑 GETTERS MATTER HERE. A detail page reuses one widget host across a\n\t * route change, so the record under a mounted consumer changes. A consumer\n\t * that could only pass a VALUE would leave this beating on the record the\n\t * reader left — the shape that puts a stranger's avatar on your page — and\n\t * `unref` alone does not call a function.\n\t *\n\t * @param {string|import('vue').Ref<string>|(() => string)} source A value, a ref or a getter.\n\t * @return {string} The part.\n\t */\n\tfunction read(source) {\n\t\treturn String((typeof source === 'function' ? source() : unref(source)) || '')\n\t}\n\n\tconst readUuid = () => read(objectUuid)\n\n\t/**\n\t * The presence endpoint of the object currently being read.\n\t *\n\t * Named `presenceUrl` rather than `endpoint` because\n\t * `tests/packaging/api-fetches-are-url-prefixed` recognises module-local\n\t * URL builders BY NAME, alongside `objectsUrl` and `agentsUrl`. A generic\n\t * `endpoint` would have to be exempted globally, which would blanket every\n\t * function of that name in the library.\n\t *\n\t * @return {string} The url, or '' when the object is not resolved yet.\n\t */\n\tfunction presenceUrl() {\n\t\tconst r = read(register)\n\t\tconst s = read(schema)\n\t\tconst i = readUuid()\n\t\tif (!r || !s || !i) {\n\t\t\treturn ''\n\t\t}\n\t\treturn generateUrl(`/apps/openregister/api/objects/${encodeURIComponent(r)}/${encodeURIComponent(s)}/${encodeURIComponent(i)}/presence`)\n\t}\n\n\t/**\n\t * Everybody present except the viewer.\n\t *\n\t * The server already leaves the caller out of the beat's answer, but a\n\t * PUSH carries the whole list — it is one payload for every recipient and\n\t * cannot be personalised. Filtering here is what stops a reader seeing\n\t * their own avatar appear the moment somebody else arrives.\n\t */\n\tconst others = computed(() => {\n\t\tconst me = getCurrentUser()?.uid\n\t\treturn present.value.filter((entry) => !me || entry?.user !== me)\n\t})\n\n\tconst count = computed(() => others.value.length)\n\n\t/**\n\t * Replace the list with whatever the server just said.\n\t *\n\t * @param {Array<object>} list The readers.\n\t * @return {void}\n\t */\n\tfunction adopt(list) {\n\t\tpresent.value = Array.isArray(list) ? list : []\n\t}\n\n\t/**\n\t * Say we are still here, and take the answer.\n\t *\n\t * @return {Promise<void>}\n\t */\n\tasync function beat() {\n\t\tconst url = presenceUrl()\n\t\tif (!url || !enabled()) {\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\tconst { data } = await axios.put(url)\n\t\t\tadopt(data?.present)\n\t\t\tactive.value = true\n\t\t\t// The server's pace, when it offers one. See FALLBACK_BEAT_SECONDS.\n\t\t\tif (Number.isFinite(data?.beatSeconds) && data.beatSeconds > 0) {\n\t\t\t\tbeatSeconds = data.beatSeconds\n\t\t\t}\n\t\t} catch {\n\t\t\t// A failed beat is NOT an empty list. The reader drops off other\n\t\t\t// people's screens in one window either way, and clearing our own\n\t\t\t// list here would tell this reader they are alone on a page two\n\t\t\t// colleagues are looking at.\n\t\t\tactive.value = false\n\t\t}\n\t}\n\n\t/**\n\t * Say we have gone.\n\t *\n\t * @return {Promise<void>}\n\t */\n\tasync function depart() {\n\t\tstop()\n\t\tawait departFrom(readUuid())\n\t\tpresent.value = []\n\t\tactive.value = false\n\t}\n\n\t/**\n\t * Leave one specific record, which may no longer be the current one.\n\t *\n\t * Takes the uuid rather than reading it, because the watcher calls this\n\t * for the record just LEFT, after the address has already moved on.\n\t *\n\t * @param {string} uuid The record to leave.\n\t * @return {Promise<void>}\n\t */\n\tasync function departFrom(uuid) {\n\t\tconst r = read(register)\n\t\tconst s = read(schema)\n\t\tif (!r || !s || !uuid) {\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\tawait axios.delete(generateUrl(`/apps/openregister/api/objects/${encodeURIComponent(r)}/${encodeURIComponent(s)}/${encodeURIComponent(uuid)}/presence`))\n\t\t} catch {\n\t\t\t// Best effort: the window expires us anyway.\n\t\t}\n\t}\n\n\t/**\n\t * Tell the server we are gone from a tab that is closing.\n\t *\n\t * `sendBeacon` because `beforeunload` gives no time for a promise: an\n\t * axios call from here is cancelled with the page. A beacon is queued by\n\t * the browser and survives the navigation, which is the only mechanism\n\t * that does.\n\t *\n\t * @return {void}\n\t */\n\tfunction beaconDepart() {\n\t\tconst url = presenceUrl()\n\t\tif (!url || !active.value) {\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\t// No DELETE from a beacon — it is always a POST — so the server's\n\t\t\t// own POST release path is used. Both reach one implementation.\n\t\t\tnavigator.sendBeacon?.(url.replace(/\\/presence$/, '/presence?_method=DELETE'))\n\t\t} catch {\n\t\t\t// Best effort.\n\t\t}\n\t}\n\n\t/**\n\t * Begin beating and listen for other people's arrivals.\n\t *\n\t * @return {void}\n\t */\n\tfunction start() {\n\t\tif (!enabled() || beatTimer) {\n\t\t\treturn\n\t\t}\n\n\t\tbeat()\n\t\tbeatTimer = setInterval(beat, beatSeconds * 1000)\n\n\t\ttry {\n\t\t\tconst live = getLiveUpdates()\n\t\t\tsubscription = live.subscribe(\n\t\t\t\tbuildObjectKey(readUuid()),\n\t\t\t\t(event, body) => {\n\t\t\t\t\t// ONLY the presence event. The same channel carries create,\n\t\t\t\t\t// update and delete, and adopting a lifecycle payload's\n\t\t\t\t\t// absent `present` key would blank the list on every save.\n\t\t\t\t\tif (body?.action === 'presence') {\n\t\t\t\t\t\tadopt(body?.present)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t{ isObject: true },\n\t\t\t)\n\t\t} catch {\n\t\t\t// No transport is not an error: the beats still answer the list,\n\t\t\t// and presence simply updates at beat pace.\n\t\t\tsubscription = null\n\t\t}\n\t}\n\n\t/**\n\t * Stop beating and listening.\n\t *\n\t * @return {void}\n\t */\n\tfunction stop() {\n\t\tif (beatTimer) {\n\t\t\tclearInterval(beatTimer)\n\t\t\tbeatTimer = null\n\t\t}\n\t\tif (subscription) {\n\t\t\ttry {\n\t\t\t\tgetLiveUpdates().unsubscribe(subscription)\n\t\t\t} catch {\n\t\t\t\t// Already gone.\n\t\t\t}\n\t\t\tsubscription = null\n\t\t}\n\t}\n\n\t// 🔴 STARTED HERE, NOT IN `onMounted`. `onMounted` only fires inside a\n\t// COMPONENT: called from a plain `effectScope`, or from a composable a\n\t// consumer wires up outside setup, it warns and never runs, and presence\n\t// would be a feature that silently does nothing in exactly the places\n\t// somebody would try it first. `tryOnScopeDispose` below works in both, so\n\t// the pairing is scope-based at both ends rather than half component and\n\t// half scope.\n\t//\n\t// Beating before mount is also the behaviour we want: a reader has the\n\t// object open the moment its page is being built, not one frame later.\n\twindow.addEventListener('beforeunload', beaconDepart)\n\tstart()\n\n\t// 🔴 THE RECORD CAN CHANGE UNDER A MOUNTED CONSUMER. A detail page reuses\n\t// one widget host across a route change, so without this the beats would go\n\t// on naming the record the reader has left: they would appear present on a\n\t// page they closed, and absent on the one they are reading. Depart the old\n\t// one first, so the people still on it are told, rather than waiting a whole\n\t// window to find out.\n\twatch(\n\t\t() => readUuid(),\n\t\t(next, previous) => {\n\t\t\tif (next === previous) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tstop()\n\t\t\tif (previous) {\n\t\t\t\tdepartFrom(previous)\n\t\t\t}\n\t\t\tpresent.value = []\n\t\t\tactive.value = false\n\t\t\tstart()\n\t\t},\n\t)\n\n\t// A consumer may only learn it needs presence AFTER mount — a detail page\n\t// whose `widgets` arrive with the manifest reads `false` here on the first\n\t// pass. Without this the beat never starts for it, and the row it gates is\n\t// dropped permanently rather than until somebody arrives.\n\twatch(\n\t\t() => enabled(),\n\t\t(on, was) => {\n\t\t\tif (on === was) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (on) {\n\t\t\t\tstart()\n\t\t\t} else {\n\t\t\t\tdepart()\n\t\t\t}\n\t\t},\n\t)\n\n\t// 🔴 VUE'S OWN `onScopeDispose`, NOT `@vueuse/core`'s `tryOnScopeDispose`.\n\t// Measured in this repo's jest on 2026-09-18: a `tryOnScopeDispose`\n\t// callback registered inside `effectScope().run()` does NOT fire on\n\t// `scope.stop()`, while Vue's own does. The cause is the dual-package\n\t// hazard — vueuse resolves its own copy of Vue and asks a different\n\t// `getCurrentScope()` — so the registration silently goes nowhere.\n\t//\n\t// A departure that never fires is the exact silent no-op this whole\n\t// feature is built to avoid: the reader stays on everybody's list for a\n\t// full window after they navigate away. One fewer indirection also means\n\t// one fewer thing that can resolve to the wrong instance.\n\t//\n\t// Guarded, because a consumer may legitimately call this outside a scope;\n\t// there it simply has no automatic cleanup and relies on the beacon and\n\t// the server's window, which is what the guard makes explicit rather than\n\t// leaving to a warning nobody reads.\n\tif (getCurrentScope()) {\n\t\tonScopeDispose(() => {\n\t\t\twindow.removeEventListener('beforeunload', beaconDepart)\n\t\t\t// Fire and forget: the scope is gone, there is nobody to await it.\n\t\t\tdepart()\n\t\t})\n\t}\n\n\treturn { others, count, active, depart }\n}\n"],"names":["axios"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"useObjectPresence.js","sources":["../../../src/composables/useObjectPresence.js"],"sourcesContent":["// SPDX-License-Identifier: EUPL-1.2\n// Copyright (C) 2026 Conduction B.V.\n\nimport { getCurrentUser, getRequestToken } from '@nextcloud/auth'\nimport axios from '@nextcloud/axios'\nimport { generateUrl } from '@nextcloud/router'\nimport { computed, getCurrentScope, onScopeDispose, ref, unref, watch } from 'vue'\nimport { buildObjectKey } from '../store/liveUpdates/eventKeys.js'\nimport { getLiveUpdates } from '../store/liveUpdates/transport.js'\n\n/**\n * How long the server believes a reader after their last beat, in seconds.\n *\n * Mirrored from `PresenceService::WINDOW_SECONDS` and used ONLY as the\n * fallback pace when a server does not answer with its own. The server's\n * `beatSeconds` wins whenever it is present, because the window is its\n * decision and a client pacing itself to a stale copy of it drops off the\n * list while its user is still reading.\n */\nconst FALLBACK_BEAT_SECONDS = 30\n\n/**\n * useObjectPresence — who else has this object open.\n *\n * 🔴 A HEARTBEAT, NOT A CONNECTION. notify_push tells the server nothing about\n * who is looking at what: a socket can be open while the tab showing this\n * object closed ten minutes ago, and a socket can drop while the reader is\n * still there. So the client says \"still here\" on a timer and the server stops\n * believing it after its window. Missing two beats reads as gone, which\n * survives a lost socket where connection tracking does not.\n *\n * 🔑 THE LIST COMES FROM TWO PLACES, AND THAT IS DELIBERATE. Every beat answers\n * the current list, so the component is correct even on an instance with no\n * notify_push at all — it simply learns at beat pace. The push is the\n * OPTIMISATION: it carries the new list on the object's own channel the moment\n * somebody arrives or leaves. Relying on the push alone would make presence a\n * feature that silently does nothing wherever notify_push is not installed,\n * which is most development instances and some production ones.\n *\n * 🔑 DEPARTURE IS SENT TWICE, ON PURPOSE. `tryOnScopeDispose` covers a route\n * change and an unmount; `beforeunload` with `sendBeacon` covers a closed tab,\n * which fires no Vue hook at all. Without the beacon a closed tab lingers for a\n * whole window on everybody else's screen; without the dispose a route change\n * does. Both are cheap and the server is idempotent about it.\n *\n * @param {string|import('vue').Ref<string>} register OpenRegister register slug.\n * @param {string|import('vue').Ref<string>} schema OpenRegister schema slug.\n * @param {string|import('vue').Ref<string>} objectUuid The object being read.\n * @param {object} [options] Optional config.\n * @param {boolean|import('vue').Ref<boolean>|(() => boolean)} [options.enabled] Set false to make the\n * whole thing inert. A ref or getter is read on every use, so a consumer whose answer changes\n * after mount turns the subscription on when it flips.\n * @return {{\n * others: import('vue').ComputedRef<Array<object>>,\n * count: import('vue').ComputedRef<number>,\n * active: import('vue').Ref<boolean>,\n * depart: () => Promise<void>\n * }} The other readers, and a way to leave early.\n */\nexport function useObjectPresence(register, schema, objectUuid, options = {}) {\n\tconst present = ref([])\n\tconst active = ref(false)\n\tlet beatTimer = null\n\tlet subscription = null\n\tlet beatSeconds = FALLBACK_BEAT_SECONDS\n\n\t// A value, a ref or a getter, like the address parts below: a consumer whose\n\t// answer changes after mount (widgets that arrive late) has to be able to say so.\n\tconst enabled = () => unref(typeof options.enabled === 'function' ? options.enabled() : options.enabled) !== false\n\n\t/**\n\t * Read an address part that may be a value, a ref OR a getter.\n\t *\n\t * 🔑 GETTERS MATTER HERE. A detail page reuses one widget host across a\n\t * route change, so the record under a mounted consumer changes. A consumer\n\t * that could only pass a VALUE would leave this beating on the record the\n\t * reader left — the shape that puts a stranger's avatar on your page — and\n\t * `unref` alone does not call a function.\n\t *\n\t * @param {string|import('vue').Ref<string>|(() => string)} source A value, a ref or a getter.\n\t * @return {string} The part.\n\t */\n\tfunction read(source) {\n\t\treturn String((typeof source === 'function' ? source() : unref(source)) || '')\n\t}\n\n\tconst readUuid = () => read(objectUuid)\n\n\t/**\n\t * The presence endpoint of the object currently being read.\n\t *\n\t * Named `presenceUrl` rather than `endpoint` because\n\t * `tests/packaging/api-fetches-are-url-prefixed` recognises module-local\n\t * URL builders BY NAME, alongside `objectsUrl` and `agentsUrl`. A generic\n\t * `endpoint` would have to be exempted globally, which would blanket every\n\t * function of that name in the library.\n\t *\n\t * @return {string} The url, or '' when the object is not resolved yet.\n\t */\n\tfunction presenceUrl() {\n\t\tconst r = read(register)\n\t\tconst s = read(schema)\n\t\tconst i = readUuid()\n\t\tif (!r || !s || !i) {\n\t\t\treturn ''\n\t\t}\n\t\treturn generateUrl(`/apps/openregister/api/objects/${encodeURIComponent(r)}/${encodeURIComponent(s)}/${encodeURIComponent(i)}/presence`)\n\t}\n\n\t/**\n\t * Everybody present except the viewer.\n\t *\n\t * The server already leaves the caller out of the beat's answer, but a\n\t * PUSH carries the whole list — it is one payload for every recipient and\n\t * cannot be personalised. Filtering here is what stops a reader seeing\n\t * their own avatar appear the moment somebody else arrives.\n\t */\n\tconst others = computed(() => {\n\t\tconst me = getCurrentUser()?.uid\n\t\treturn present.value.filter((entry) => !me || entry?.user !== me)\n\t})\n\n\tconst count = computed(() => others.value.length)\n\n\t/**\n\t * Replace the list with whatever the server just said.\n\t *\n\t * @param {Array<object>} list The readers.\n\t * @return {void}\n\t */\n\tfunction adopt(list) {\n\t\tpresent.value = Array.isArray(list) ? list : []\n\t}\n\n\t/**\n\t * Say we are still here, and take the answer.\n\t *\n\t * @return {Promise<void>}\n\t */\n\tasync function beat() {\n\t\tconst url = presenceUrl()\n\t\tif (!url || !enabled()) {\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\tconst { data } = await axios.put(url)\n\t\t\tadopt(data?.present)\n\t\t\tactive.value = true\n\t\t\t// The server's pace, when it offers one. See FALLBACK_BEAT_SECONDS.\n\t\t\tif (Number.isFinite(data?.beatSeconds) && data.beatSeconds > 0) {\n\t\t\t\tbeatSeconds = data.beatSeconds\n\t\t\t}\n\t\t} catch {\n\t\t\t// A failed beat is NOT an empty list. The reader drops off other\n\t\t\t// people's screens in one window either way, and clearing our own\n\t\t\t// list here would tell this reader they are alone on a page two\n\t\t\t// colleagues are looking at.\n\t\t\tactive.value = false\n\t\t}\n\t}\n\n\t/**\n\t * Say we have gone.\n\t *\n\t * @return {Promise<void>}\n\t */\n\tasync function depart() {\n\t\tstop()\n\t\tawait departFrom(readUuid())\n\t\tpresent.value = []\n\t\tactive.value = false\n\t}\n\n\t/**\n\t * Leave one specific record, which may no longer be the current one.\n\t *\n\t * Takes the uuid rather than reading it, because the watcher calls this\n\t * for the record just LEFT, after the address has already moved on.\n\t *\n\t * @param {string} uuid The record to leave.\n\t * @return {Promise<void>}\n\t */\n\tasync function departFrom(uuid) {\n\t\tconst r = read(register)\n\t\tconst s = read(schema)\n\t\tif (!r || !s || !uuid) {\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\tawait axios.delete(generateUrl(`/apps/openregister/api/objects/${encodeURIComponent(r)}/${encodeURIComponent(s)}/${encodeURIComponent(uuid)}/presence`))\n\t\t} catch {\n\t\t\t// Best effort: the window expires us anyway.\n\t\t}\n\t}\n\n\t/**\n\t * Tell the server we are gone from a tab that is closing.\n\t *\n\t * `sendBeacon` because `beforeunload` gives no time for a promise: an\n\t * axios call from here is cancelled with the page. A beacon is queued by\n\t * the browser and survives the navigation, which is the only mechanism\n\t * that does.\n\t *\n\t * 🔴 THE TOKEN RIDES IN THE BODY. A beacon cannot set headers, so the\n\t * `requesttoken` header axios adds is missing, and the departure route\n\t * keeps Nextcloud's CSRF check. Nextcloud also reads the token from a\n\t * POST field, so it goes in a form body. Without it the beacon answers\n\t * 412; before OpenRegister registered POST on `/presence` it answered 405\n\t * (seen on cloud.conduction.nl on every dossiq case page).\n\t *\n\t * @return {void}\n\t * @spec openspec/changes/no-calls-to-missing-apps-and-presence-beacon/specs/object-presence/spec.md#requirement-a-closing-tab-departs-through-a-route-that-exists\n\t */\n\tfunction beaconDepart() {\n\t\tconst url = presenceUrl()\n\t\tif (!url || !active.value) {\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\t// No DELETE from a beacon, it is always a POST, so the server's\n\t\t\t// POST departure route is used, marked `_method=DELETE`. Both reach\n\t\t\t// one implementation.\n\t\t\tconst body = new FormData()\n\t\t\tbody.append('requesttoken', getRequestToken() || '')\n\t\t\tnavigator.sendBeacon?.(url.replace(/\\/presence$/, '/presence?_method=DELETE'), body)\n\t\t} catch {\n\t\t\t// Best effort.\n\t\t}\n\t}\n\n\t/**\n\t * Begin beating and listen for other people's arrivals.\n\t *\n\t * @return {void}\n\t */\n\tfunction start() {\n\t\tif (!enabled() || beatTimer) {\n\t\t\treturn\n\t\t}\n\n\t\tbeat()\n\t\tbeatTimer = setInterval(beat, beatSeconds * 1000)\n\n\t\ttry {\n\t\t\tconst live = getLiveUpdates()\n\t\t\tsubscription = live.subscribe(\n\t\t\t\tbuildObjectKey(readUuid()),\n\t\t\t\t(event, body) => {\n\t\t\t\t\t// ONLY the presence event. The same channel carries create,\n\t\t\t\t\t// update and delete, and adopting a lifecycle payload's\n\t\t\t\t\t// absent `present` key would blank the list on every save.\n\t\t\t\t\tif (body?.action === 'presence') {\n\t\t\t\t\t\tadopt(body?.present)\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t{ isObject: true },\n\t\t\t)\n\t\t} catch {\n\t\t\t// No transport is not an error: the beats still answer the list,\n\t\t\t// and presence simply updates at beat pace.\n\t\t\tsubscription = null\n\t\t}\n\t}\n\n\t/**\n\t * Stop beating and listening.\n\t *\n\t * @return {void}\n\t */\n\tfunction stop() {\n\t\tif (beatTimer) {\n\t\t\tclearInterval(beatTimer)\n\t\t\tbeatTimer = null\n\t\t}\n\t\tif (subscription) {\n\t\t\ttry {\n\t\t\t\tgetLiveUpdates().unsubscribe(subscription)\n\t\t\t} catch {\n\t\t\t\t// Already gone.\n\t\t\t}\n\t\t\tsubscription = null\n\t\t}\n\t}\n\n\t// 🔴 STARTED HERE, NOT IN `onMounted`. `onMounted` only fires inside a\n\t// COMPONENT: called from a plain `effectScope`, or from a composable a\n\t// consumer wires up outside setup, it warns and never runs, and presence\n\t// would be a feature that silently does nothing in exactly the places\n\t// somebody would try it first. `tryOnScopeDispose` below works in both, so\n\t// the pairing is scope-based at both ends rather than half component and\n\t// half scope.\n\t//\n\t// Beating before mount is also the behaviour we want: a reader has the\n\t// object open the moment its page is being built, not one frame later.\n\twindow.addEventListener('beforeunload', beaconDepart)\n\tstart()\n\n\t// 🔴 THE RECORD CAN CHANGE UNDER A MOUNTED CONSUMER. A detail page reuses\n\t// one widget host across a route change, so without this the beats would go\n\t// on naming the record the reader has left: they would appear present on a\n\t// page they closed, and absent on the one they are reading. Depart the old\n\t// one first, so the people still on it are told, rather than waiting a whole\n\t// window to find out.\n\twatch(\n\t\t() => readUuid(),\n\t\t(next, previous) => {\n\t\t\tif (next === previous) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tstop()\n\t\t\tif (previous) {\n\t\t\t\tdepartFrom(previous)\n\t\t\t}\n\t\t\tpresent.value = []\n\t\t\tactive.value = false\n\t\t\tstart()\n\t\t},\n\t)\n\n\t// A consumer may only learn it needs presence AFTER mount — a detail page\n\t// whose `widgets` arrive with the manifest reads `false` here on the first\n\t// pass. Without this the beat never starts for it, and the row it gates is\n\t// dropped permanently rather than until somebody arrives.\n\twatch(\n\t\t() => enabled(),\n\t\t(on, was) => {\n\t\t\tif (on === was) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (on) {\n\t\t\t\tstart()\n\t\t\t} else {\n\t\t\t\tdepart()\n\t\t\t}\n\t\t},\n\t)\n\n\t// 🔴 VUE'S OWN `onScopeDispose`, NOT `@vueuse/core`'s `tryOnScopeDispose`.\n\t// Measured in this repo's jest on 2026-09-18: a `tryOnScopeDispose`\n\t// callback registered inside `effectScope().run()` does NOT fire on\n\t// `scope.stop()`, while Vue's own does. The cause is the dual-package\n\t// hazard — vueuse resolves its own copy of Vue and asks a different\n\t// `getCurrentScope()` — so the registration silently goes nowhere.\n\t//\n\t// A departure that never fires is the exact silent no-op this whole\n\t// feature is built to avoid: the reader stays on everybody's list for a\n\t// full window after they navigate away. One fewer indirection also means\n\t// one fewer thing that can resolve to the wrong instance.\n\t//\n\t// Guarded, because a consumer may legitimately call this outside a scope;\n\t// there it simply has no automatic cleanup and relies on the beacon and\n\t// the server's window, which is what the guard makes explicit rather than\n\t// leaving to a warning nobody reads.\n\tif (getCurrentScope()) {\n\t\tonScopeDispose(() => {\n\t\t\twindow.removeEventListener('beforeunload', beaconDepart)\n\t\t\t// Fire and forget: the scope is gone, there is nobody to await it.\n\t\t\tdepart()\n\t\t})\n\t}\n\n\treturn { others, count, active, depart }\n}\n"],"names":["axios"],"mappings":"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"}
|
package/dist/esm/index.js
CHANGED
|
@@ -342,6 +342,7 @@ export { defaultPageTypes } from './components/CnPageRenderer/pageTypes.js';
|
|
|
342
342
|
export { fetchWebdavFolderTree } from './components/CnFolderSidebar/webdavFolders.js';
|
|
343
343
|
export { mdiCatalogue, vmdiCatalogue } from './components/CnIconBrowser/iconCatalogue.js';
|
|
344
344
|
export { registerIcons } from './components/CnIcon/CnIcon.vue2.js';
|
|
345
|
+
export { createOpenRegisterSource } from './components/CnFilesBrowser/openRegisterSource.js';
|
|
345
346
|
export { default as CnBrandStripe } from './components/CnBrandStripe/CnBrandStripe.vue.js';
|
|
346
347
|
export { default as CnSegmentedControl } from './components/CnSegmentedControl/CnSegmentedControl.vue.js';
|
|
347
348
|
export { default as CnStackedBarWidget } from './components/CnStackedBarWidget/CnStackedBarWidget.vue.js';
|
package/dist/esm/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
package/dist/esm/l10n/en.json.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
var translations = {
|
|
2
|
+
"You cannot see these files": "You cannot see these files",
|
|
3
|
+
"Delete {name}? This cannot be undone here.": "Delete {name}? This cannot be undone here.",
|
|
2
4
|
"Linked items": "Linked items",
|
|
3
5
|
"Still loading: {sections}": "Still loading: {sections}",
|
|
4
6
|
"Could not load {section}.": "Could not load {section}.",
|
|
@@ -113,6 +115,7 @@ var translations = {
|
|
|
113
115
|
"Saved just now": "Saved just now",
|
|
114
116
|
"Saved {time}": "Saved {time}",
|
|
115
117
|
Saved: "Saved",
|
|
118
|
+
"Draft kept on this device": "Draft kept on this device",
|
|
116
119
|
Publish: "Publish",
|
|
117
120
|
"Edit {title} (draft)": "Edit {title} (draft)",
|
|
118
121
|
"Also search inside files": "Also search inside files",
|
|
@@ -452,6 +455,12 @@ var translations = {
|
|
|
452
455
|
"NC Activity is currently unavailable.": "NC Activity is currently unavailable.",
|
|
453
456
|
"Could not load activity.": "Could not load activity.",
|
|
454
457
|
Today: "Today",
|
|
458
|
+
"Last 8 hours": "Last 8 hours",
|
|
459
|
+
"Last 24 hours": "Last 24 hours",
|
|
460
|
+
"Last 7 days": "Last 7 days",
|
|
461
|
+
"Last 30 days": "Last 30 days",
|
|
462
|
+
"Last 90 days": "Last 90 days",
|
|
463
|
+
"Custom range": "Custom range",
|
|
455
464
|
Yesterday: "Yesterday",
|
|
456
465
|
Earlier: "Earlier",
|
|
457
466
|
System: "System",
|
|
@@ -564,6 +573,8 @@ var translations = {
|
|
|
564
573
|
Order: "Order",
|
|
565
574
|
"Owner:": "Owner:",
|
|
566
575
|
"Page {current} of {total}": "Page {current} of {total}",
|
|
576
|
+
"{shown} of {total}": "{shown} of {total}",
|
|
577
|
+
"{from}–{to} of {total}": "{from}–{to} of {total}",
|
|
567
578
|
"Parent schemas:": "Parent schemas:",
|
|
568
579
|
Password: "Password",
|
|
569
580
|
"Pattern (regex)": "Pattern (regex)",
|
|
@@ -763,6 +774,27 @@ var translations = {
|
|
|
763
774
|
"Message input": "Message input",
|
|
764
775
|
"Start new chat": "Start new chat",
|
|
765
776
|
History: "History",
|
|
777
|
+
"No audit entries yet": "No audit entries yet",
|
|
778
|
+
Meetings: "Meetings",
|
|
779
|
+
"No meetings": "No meetings",
|
|
780
|
+
"Open in Calendar": "Open in Calendar",
|
|
781
|
+
"(no title)": "(no title)",
|
|
782
|
+
"Could not load meetings.": "Could not load meetings.",
|
|
783
|
+
"Could not load meeting.": "Could not load meeting.",
|
|
784
|
+
"Calendar is currently unavailable.": "Calendar is currently unavailable.",
|
|
785
|
+
"Show all {count} fields": "Show all {count} fields",
|
|
786
|
+
"Show less": "Show less",
|
|
787
|
+
"{count} more": "{count} more",
|
|
788
|
+
"Identifier: {identifier}": "Identifier: {identifier}",
|
|
789
|
+
"What happened, who did it and when, newest first": "What happened, who did it and when, newest first",
|
|
790
|
+
"{app} is not installed": "{app} is not installed",
|
|
791
|
+
"Install and enable the {app} app to see this.": "Install and enable the {app} app to see this.",
|
|
792
|
+
"{title} needs the {app} app. Install and enable it to see this.": "{title} needs the {app} app. Install and enable it to see this.",
|
|
793
|
+
"View all ({total})": "View all ({total})",
|
|
794
|
+
"No items": "No items",
|
|
795
|
+
"Nothing here yet": "Nothing here yet",
|
|
796
|
+
"No items match this filter": "No items match this filter",
|
|
797
|
+
"Could not load these records": "Could not load these records",
|
|
766
798
|
"No conversations yet": "No conversations yet",
|
|
767
799
|
"Connecting...": "Connecting...",
|
|
768
800
|
"Loading conversations...": "Loading conversations...",
|
|
@@ -1301,7 +1333,34 @@ var translations = {
|
|
|
1301
1333
|
"Filter by {column}, active": "Filter by {column}, active",
|
|
1302
1334
|
"Turn notifications off": "Turn notifications off",
|
|
1303
1335
|
"Turn notifications on": "Turn notifications on",
|
|
1304
|
-
"Could not change notifications.": "Could not change notifications."
|
|
1336
|
+
"Could not change notifications.": "Could not change notifications.",
|
|
1337
|
+
"Actions for the selection": "Actions for the selection",
|
|
1338
|
+
"Active:": "Active:",
|
|
1339
|
+
Filter: "Filter",
|
|
1340
|
+
List: "List",
|
|
1341
|
+
"Map": "Map",
|
|
1342
|
+
"Remove filter: {label}": "Remove filter: {label}",
|
|
1343
|
+
"Search and columns": "Search and columns",
|
|
1344
|
+
"Sort by": "Sort by",
|
|
1345
|
+
"View mode": "View mode",
|
|
1346
|
+
"With the selected {plural}": "With the selected {plural}",
|
|
1347
|
+
items: "items",
|
|
1348
|
+
"{count} active": "{count} active",
|
|
1349
|
+
"Delete \"{name}\"": "Delete \"{name}\"",
|
|
1350
|
+
"No saved views yet": "No saved views yet",
|
|
1351
|
+
"No views with this label": "No views with this label",
|
|
1352
|
+
"Save current view…": "Save current view…",
|
|
1353
|
+
"Save view": "Save view",
|
|
1354
|
+
"Saved views": "Saved views",
|
|
1355
|
+
"Show only views labelled \"{label}\"": "Show only views labelled \"{label}\"",
|
|
1356
|
+
"Stop filtering on \"{label}\"": "Stop filtering on \"{label}\"",
|
|
1357
|
+
"{name}, level {level}": "{name}, level {level}",
|
|
1358
|
+
"{name}. Parts of this view come from one you cannot see.": "{name}. Parts of this view come from one you cannot see.",
|
|
1359
|
+
"More filters": "More filters",
|
|
1360
|
+
"Quick filters": "Quick filters",
|
|
1361
|
+
"{count} filters": "{count} filters",
|
|
1362
|
+
"{count} more filters": "{count} more filters",
|
|
1363
|
+
"{label} — show the other filters": "{label} — show the other filters"
|
|
1305
1364
|
};
|
|
1306
1365
|
var plurals = {
|
|
1307
1366
|
"%n year": [
|
|
@@ -1312,6 +1371,10 @@ var plurals = {
|
|
|
1312
1371
|
"%n month",
|
|
1313
1372
|
"%n months"
|
|
1314
1373
|
],
|
|
1374
|
+
"%n hour": [
|
|
1375
|
+
"%n hour",
|
|
1376
|
+
"%n hours"
|
|
1377
|
+
],
|
|
1315
1378
|
"%n day": [
|
|
1316
1379
|
"%n day",
|
|
1317
1380
|
"%n days"
|
|
@@ -1367,6 +1430,10 @@ var plurals = {
|
|
|
1367
1430
|
"in {count} day": [
|
|
1368
1431
|
"in {count} day",
|
|
1369
1432
|
"in {count} days"
|
|
1433
|
+
],
|
|
1434
|
+
"{count} more filter": [
|
|
1435
|
+
"{count} more filter",
|
|
1436
|
+
"{count} more filters"
|
|
1370
1437
|
]
|
|
1371
1438
|
};
|
|
1372
1439
|
var en = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"en.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"en.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
package/dist/esm/l10n/nl.json.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
var translations = {
|
|
2
|
+
"You cannot see these files": "Je kunt deze bestanden niet zien",
|
|
3
|
+
"Delete {name}? This cannot be undone here.": "{name} verwijderen? Dit kun je hier niet ongedaan maken.",
|
|
2
4
|
"Linked items": "Gekoppelde items",
|
|
3
5
|
"Still loading: {sections}": "Nog aan het laden: {sections}",
|
|
4
6
|
"Could not load {section}.": "{section} kon niet worden geladen.",
|
|
@@ -113,6 +115,7 @@ var translations = {
|
|
|
113
115
|
"Saved just now": "Zojuist opgeslagen",
|
|
114
116
|
"Saved {time}": "Opgeslagen {time}",
|
|
115
117
|
Saved: "Opgeslagen",
|
|
118
|
+
"Draft kept on this device": "Concept bewaard op dit apparaat",
|
|
116
119
|
"Edit {title} (draft)": "{title} bewerken (concept)",
|
|
117
120
|
"Also search inside files": "Zoek ook in bestanden",
|
|
118
121
|
"File matches are limited to the best 50.": "Bestandstreffers zijn beperkt tot de beste 50.",
|
|
@@ -451,6 +454,12 @@ var translations = {
|
|
|
451
454
|
"NC Activity is currently unavailable.": "NC Activiteit is momenteel niet beschikbaar.",
|
|
452
455
|
"Could not load activity.": "Kon activiteit niet laden.",
|
|
453
456
|
Today: "Vandaag",
|
|
457
|
+
"Last 8 hours": "Laatste 8 uur",
|
|
458
|
+
"Last 24 hours": "Laatste 24 uur",
|
|
459
|
+
"Last 7 days": "Laatste 7 dagen",
|
|
460
|
+
"Last 30 days": "Laatste 30 dagen",
|
|
461
|
+
"Last 90 days": "Laatste 90 dagen",
|
|
462
|
+
"Custom range": "Aangepaste periode",
|
|
454
463
|
Yesterday: "Gisteren",
|
|
455
464
|
Earlier: "Eerder",
|
|
456
465
|
System: "Systeem",
|
|
@@ -563,6 +572,8 @@ var translations = {
|
|
|
563
572
|
Order: "Volgorde",
|
|
564
573
|
"Owner:": "Eigenaar:",
|
|
565
574
|
"Page {current} of {total}": "Pagina {current} van {total}",
|
|
575
|
+
"{shown} of {total}": "{shown} van {total}",
|
|
576
|
+
"{from}–{to} of {total}": "{from}–{to} van {total}",
|
|
566
577
|
"Parent schemas:": "Bovenliggende schema's:",
|
|
567
578
|
Password: "Wachtwoord",
|
|
568
579
|
"Pattern (regex)": "Patroon (regex)",
|
|
@@ -761,7 +772,28 @@ var translations = {
|
|
|
761
772
|
"AI assistant": "AI-assistent",
|
|
762
773
|
"Message input": "Berichtinvoer",
|
|
763
774
|
"Start new chat": "Nieuwe chat starten",
|
|
764
|
-
History: "
|
|
775
|
+
History: "Historie",
|
|
776
|
+
"No audit entries yet": "Nog geen wijzigingen vastgelegd",
|
|
777
|
+
Meetings: "Afspraken",
|
|
778
|
+
"No meetings": "Geen afspraken",
|
|
779
|
+
"Open in Calendar": "Openen in Agenda",
|
|
780
|
+
"(no title)": "(geen titel)",
|
|
781
|
+
"Could not load meetings.": "De afspraken konden niet worden geladen.",
|
|
782
|
+
"Could not load meeting.": "De afspraak kon niet worden geladen.",
|
|
783
|
+
"Calendar is currently unavailable.": "De agenda is nu niet beschikbaar.",
|
|
784
|
+
"Show all {count} fields": "Alle {count} velden tonen",
|
|
785
|
+
"Show less": "Minder tonen",
|
|
786
|
+
"{count} more": "Nog {count}",
|
|
787
|
+
"Identifier: {identifier}": "Kenmerk: {identifier}",
|
|
788
|
+
"What happened, who did it and when, newest first": "Wat er gebeurde, door wie en wanneer, nieuwste eerst",
|
|
789
|
+
"{app} is not installed": "{app} is niet geïnstalleerd",
|
|
790
|
+
"Install and enable the {app} app to see this.": "Installeer en activeer de app {app} om dit te zien.",
|
|
791
|
+
"{title} needs the {app} app. Install and enable it to see this.": "{title} heeft de app {app} nodig. Installeer en activeer die om dit te zien.",
|
|
792
|
+
"View all ({total})": "Alles bekijken ({total})",
|
|
793
|
+
"No items": "Geen items",
|
|
794
|
+
"Nothing here yet": "Hier staat nog niets",
|
|
795
|
+
"No items match this filter": "Geen items voldoen aan dit filter",
|
|
796
|
+
"Could not load these records": "Deze gegevens konden niet worden geladen",
|
|
765
797
|
"No conversations yet": "Nog geen gesprekken",
|
|
766
798
|
"Connecting...": "Verbinden...",
|
|
767
799
|
"Loading conversations...": "Gesprekken laden...",
|
|
@@ -1352,7 +1384,78 @@ var translations = {
|
|
|
1352
1384
|
"Filter by {column}, active": "Filteren op {column}, actief",
|
|
1353
1385
|
"Turn notifications off": "Meldingen uitzetten",
|
|
1354
1386
|
"Turn notifications on": "Meldingen aanzetten",
|
|
1355
|
-
"Could not change notifications.": "Meldingen konden niet worden gewijzigd."
|
|
1387
|
+
"Could not change notifications.": "Meldingen konden niet worden gewijzigd.",
|
|
1388
|
+
"Actions for the selection": "Acties voor de selectie",
|
|
1389
|
+
"Active:": "Actief:",
|
|
1390
|
+
Filter: "Filter",
|
|
1391
|
+
List: "Lijst",
|
|
1392
|
+
"Map": "Kaart",
|
|
1393
|
+
"Remove filter: {label}": "Filter verwijderen: {label}",
|
|
1394
|
+
"Search and columns": "Zoeken en kolommen",
|
|
1395
|
+
"Sort by": "Sorteren op",
|
|
1396
|
+
"View mode": "Weergave",
|
|
1397
|
+
"With the selected {plural}": "Met de geselecteerde {plural}",
|
|
1398
|
+
items: "items",
|
|
1399
|
+
"{count} active": "{count} actief",
|
|
1400
|
+
"Delete \"{name}\"": "\"{name}\" verwijderen",
|
|
1401
|
+
"No saved views yet": "Nog geen opgeslagen weergaven",
|
|
1402
|
+
"No views with this label": "Geen weergaven met dit label",
|
|
1403
|
+
"Save current view…": "Huidige weergave opslaan…",
|
|
1404
|
+
"Save view": "Weergave opslaan",
|
|
1405
|
+
"Saved views": "Opgeslagen weergaven",
|
|
1406
|
+
"Show only views labelled \"{label}\"": "Alleen weergaven met label \"{label}\" tonen",
|
|
1407
|
+
"Stop filtering on \"{label}\"": "Niet meer filteren op \"{label}\"",
|
|
1408
|
+
"{name}, level {level}": "{name}, niveau {level}",
|
|
1409
|
+
"{name}. Parts of this view come from one you cannot see.": "{name}. Delen van deze weergave komen uit een weergave die u niet kunt zien.",
|
|
1410
|
+
"More filters": "Meer filters",
|
|
1411
|
+
"Quick filters": "Snelfilters",
|
|
1412
|
+
"{count} filters": "{count} filters",
|
|
1413
|
+
"{count} more filters": "{count} filters meer",
|
|
1414
|
+
"{label} — show the other filters": "{label}, toon de andere filters",
|
|
1415
|
+
"A file or folder with that name is already here": "Hier staat al een bestand of map met die naam",
|
|
1416
|
+
"A file with that name is already here": "Hier staat al een bestand met die naam",
|
|
1417
|
+
"A name cannot contain a slash": "Een naam kan geen schuine streep bevatten",
|
|
1418
|
+
"Actions for {name}": "Acties voor {name}",
|
|
1419
|
+
Download: "Downloaden",
|
|
1420
|
+
"Drop files here, or use New": "Sleep bestanden hierheen, of gebruik Nieuw",
|
|
1421
|
+
"Folder name": "Mapnaam",
|
|
1422
|
+
Modified: "Gewijzigd",
|
|
1423
|
+
New: "Nieuw",
|
|
1424
|
+
"New folder": "Nieuwe map",
|
|
1425
|
+
"New name": "Nieuwe naam",
|
|
1426
|
+
Rename: "Naam wijzigen",
|
|
1427
|
+
"Show in Files": "Tonen in Bestanden",
|
|
1428
|
+
Size: "Grootte",
|
|
1429
|
+
"The folder could not be created": "De map kon niet worden gemaakt",
|
|
1430
|
+
"The folder could not be read": "De map kon niet worden gelezen",
|
|
1431
|
+
"The name could not be changed": "De naam kon niet worden gewijzigd",
|
|
1432
|
+
"The upload failed": "Het uploaden is mislukt",
|
|
1433
|
+
"This folder is empty": "Deze map is leeg",
|
|
1434
|
+
"This folder no longer exists": "Deze map bestaat niet meer",
|
|
1435
|
+
"Try again": "Opnieuw proberen",
|
|
1436
|
+
"Upload files": "Bestanden uploaden",
|
|
1437
|
+
"You cannot see this folder": "Je kunt deze map niet zien",
|
|
1438
|
+
"{action} did not succeed": "{action} is niet gelukt",
|
|
1439
|
+
"Error:": "Fout:",
|
|
1440
|
+
optional: "optioneel",
|
|
1441
|
+
"Copy link": "Link kopiëren",
|
|
1442
|
+
"Details and sharing": "Details en delen",
|
|
1443
|
+
"Link copied": "Link gekopieerd",
|
|
1444
|
+
"Share uploaded files": "Geüploade bestanden delen",
|
|
1445
|
+
"The link could not be copied": "De link kon niet worden gekopieerd",
|
|
1446
|
+
"Invalid JSON: {msg}": "Ongeldige JSON: {msg}",
|
|
1447
|
+
"Loading the fields this choice adds.": "De velden van deze keuze worden geladen.",
|
|
1448
|
+
Restore: "Herstellen",
|
|
1449
|
+
"Save draft": "Concept opslaan",
|
|
1450
|
+
"Saved {title}": "{title} opgeslagen",
|
|
1451
|
+
"Saved, but some rows were not saved: {rows}": "Opgeslagen, maar sommige rijen zijn niet opgeslagen: {rows}",
|
|
1452
|
+
"The list of choices could not be loaded. Enter the value by hand.": "De keuzelijst kon niet worden geladen. Vul de waarde zelf in.",
|
|
1453
|
+
"You have unsaved changes from an earlier visit.": "Je hebt niet-opgeslagen wijzigingen van een eerder bezoek.",
|
|
1454
|
+
"no longer offered": "niet meer beschikbaar",
|
|
1455
|
+
"1 day left": "Nog 1 dag",
|
|
1456
|
+
"{count} days left": "Nog {count} dagen",
|
|
1457
|
+
"1 day overdue": "1 dag te laat",
|
|
1458
|
+
"{count} days overdue": "{count} dagen te laat"
|
|
1356
1459
|
};
|
|
1357
1460
|
var plurals = {
|
|
1358
1461
|
"%n year": [
|
|
@@ -1363,6 +1466,10 @@ var plurals = {
|
|
|
1363
1466
|
"%n maand",
|
|
1364
1467
|
"%n maanden"
|
|
1365
1468
|
],
|
|
1469
|
+
"%n hour": [
|
|
1470
|
+
"%n uur",
|
|
1471
|
+
"%n uur"
|
|
1472
|
+
],
|
|
1366
1473
|
"%n day": [
|
|
1367
1474
|
"%n dag",
|
|
1368
1475
|
"%n dagen"
|
|
@@ -1418,6 +1525,10 @@ var plurals = {
|
|
|
1418
1525
|
"in {count} day": [
|
|
1419
1526
|
"over {count} dag",
|
|
1420
1527
|
"over {count} dagen"
|
|
1528
|
+
],
|
|
1529
|
+
"{count} more filter": [
|
|
1530
|
+
"Nog {count} filter",
|
|
1531
|
+
"Nog {count} filters"
|
|
1421
1532
|
]
|
|
1422
1533
|
};
|
|
1423
1534
|
var nl = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nl.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"nl.json.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|