This is an automated email from the ASF dual-hosted git repository. wu-sheng pushed a commit to branch docs/accuracy-and-principles in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
commit 5683cc502f90775b3a6bd36d368c2557f283301b Author: Wu Sheng <[email protected]> AuthorDate: Tue Jun 2 17:11:43 2026 +0800 feat(admin): import / export for dashboard templates and translations Add Export and Import to all four template admin editors. Export downloads the in-use version (live on OAP, else the bundled default) as a JSON file; Import validates a file and loads it as a browser-local draft that the operator previews and then publishes with "Check diff & push" — Import never writes OAP directly. - Overview templates, Layer dashboards, 3D-map config: act on the source template. Import targets the template the file names (overview can create or restore an id; layer targets a layer already loaded here; 3D is the singleton), and rejects a wrong-kind / malformed file. - Translations page: per-locale Export/Import of the in-use overlay. Source templates and their translations live on separate pages, so their import/export are separate too — each on its own page. Shared client-side helper (file download/pick, export envelope, per-kind + overlay validation) with unit tests; new UI strings translated across all eight locales; CHANGELOG and operator docs updated. --- CHANGELOG.md | 17 ++ .../admin/_shared/templatePortability.test.ts | 161 +++++++++++++ .../features/admin/_shared/templatePortability.ts | 268 +++++++++++++++++++++ .../features/admin/infra-3d/Infra3dAdminView.vue | 57 +++++ .../admin/layer-templates/LayerDashboardsAdmin.vue | 62 +++++ .../overview-templates/OverviewTemplatesAdmin.vue | 61 ++++- .../admin/translations/TranslationsView.vue | 106 +++++++- apps/ui/src/i18n/locales/de.json | 15 +- apps/ui/src/i18n/locales/en.json | 15 +- apps/ui/src/i18n/locales/es.json | 15 +- apps/ui/src/i18n/locales/fr.json | 15 +- apps/ui/src/i18n/locales/ja.json | 15 +- apps/ui/src/i18n/locales/ko.json | 15 +- apps/ui/src/i18n/locales/pt.json | 15 +- apps/ui/src/i18n/locales/zh-CN.json | 15 +- docs/customization/i18n.md | 6 + docs/customization/layer-templates.md | 8 + docs/customization/overview-templates.md | 8 + docs/operate/infra-3d-map.md | 7 + 19 files changed, 871 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 921875a..851d697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ packages) plus the BFF's `HORIZON_VERSION` default. ## 0.7.0 +### Dashboard template portability + +- Every template admin page — Overview templates, Layer dashboards, and the + 3D-map config — now has **Export** and **Import** actions. Export downloads + the *in-use* version (what end users render: the version live on OAP, or the + bundled default when OAP has none) as a JSON file, for backup, sharing, or + moving a dashboard to another OAP. Import reads a JSON file, validates it, + and loads it as a local draft in this browser — preview it, then publish + with “Check diff & push” as usual. Importing never writes OAP directly. + Overview import can recreate a deleted dashboard or seed a brand-new one; + layer import targets a layer already present on this deployment. +- The **Translations** page has matching **Export** / **Import**, scoped to + the current language: export the in-use translation for a template + locale + as a JSON file, or import one as a local draft to review and push. (Source + templates and their translations are edited on separate pages, so their + import/export are separate too — each on its own page.) + ### Documentation & release tooling - The website docs were brought current with the 0.6.0 build and the diff --git a/apps/ui/src/features/admin/_shared/templatePortability.test.ts b/apps/ui/src/features/admin/_shared/templatePortability.test.ts new file mode 100644 index 0000000..e014c11 --- /dev/null +++ b/apps/ui/src/features/admin/_shared/templatePortability.test.ts @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { + buildExportEnvelope, + buildOverlayExportEnvelope, + parseOverlayImport, + validateImport, +} from './templatePortability'; + +const overview = { id: 'services', title: 'Services', widgets: [] }; +const layer = { key: 'mesh', alias: 'Service Mesh' }; +const infra3d = { filter: { layer: '.*' }, levels: [], layers: {} }; + +function env(kind: string, name: string, content: unknown): string { + return JSON.stringify({ name, kind, version: 1, content }); +} + +describe('validateImport', () => { + it('accepts an overview envelope and unwraps to bare content', () => { + const r = validateImport('overview', env('overview', 'horizon.overview.services', overview)); + expect(r).toEqual({ ok: true, key: 'services', content: overview }); + }); + + it('accepts bare overview content, deriving the key from id', () => { + const r = validateImport('overview', JSON.stringify(overview)); + expect(r.ok && r.key).toBe('services'); + expect(r.ok && r.content).toEqual(overview); + }); + + it('rejects a layer envelope dropped on the overview page (cross-kind)', () => { + const r = validateImport('overview', env('layer', 'horizon.layer.MESH', layer)); + expect(r.ok).toBe(false); + expect(!r.ok && r.error).toMatch(/layer dashboard/); + }); + + it('rejects malformed JSON', () => { + const r = validateImport('overview', '{ not json'); + expect(r.ok).toBe(false); + expect(!r.ok && r.error).toMatch(/valid JSON/); + }); + + it('rejects an overview without a widgets array', () => { + const r = validateImport('overview', JSON.stringify({ id: 'x', title: 'x' })); + expect(r.ok).toBe(false); + }); + + it('upper-cases the derived layer key (envelope and bare)', () => { + expect(validateImport('layer', env('layer', 'horizon.layer.MESH', layer)).ok && + (validateImport('layer', env('layer', 'horizon.layer.MESH', layer)) as { key: string }).key).toBe('MESH'); + const bare = validateImport('layer', JSON.stringify(layer)); + expect(bare.ok && bare.key).toBe('MESH'); + }); + + it('accepts a 3D-map config and derives the singleton key', () => { + const r = validateImport('infra-3d', env('infra-3d', 'horizon.infra-3d.config', infra3d)); + expect(r).toEqual({ ok: true, key: 'config', content: infra3d }); + }); + + it('rejects a 3D-map config missing levels/layers/filter', () => { + const r = validateImport('infra-3d', JSON.stringify({ filter: { layer: '.*' } })); + expect(r.ok).toBe(false); + }); +}); + +describe('buildExportEnvelope', () => { + it('wraps content in the canonical { name, kind, version, content } shape', () => { + expect(buildExportEnvelope('overview', 'horizon.overview.services', overview)).toEqual({ + name: 'horizon.overview.services', + kind: 'overview', + version: 1, + content: overview, + }); + }); +}); + +const overlay = { title: '服务', widgets: [{ title: '前 20 接口' }] }; + +describe('parseOverlayImport', () => { + it('accepts an overlay envelope and reports its template + locale', () => { + const file = JSON.stringify({ + name: 'horizon.layer.MESH.i18n.zh-CN', + kind: 'layer', + version: 1, + locale: 'zh-CN', + content: overlay, + }); + expect(parseOverlayImport(file)).toEqual({ + ok: true, + kind: 'layer', + sourceName: 'horizon.layer.MESH', + locale: 'zh-CN', + content: overlay, + }); + }); + + it('derives the locale from the name tail when no locale field is set', () => { + const file = JSON.stringify({ + name: 'horizon.overview.services.i18n.ja', + kind: 'overview', + version: 1, + content: overlay, + }); + const r = parseOverlayImport(file); + expect(r.ok && r.locale).toBe('ja'); + expect(r.ok && r.sourceName).toBe('horizon.overview.services'); + }); + + it('accepts a bare overlay object with no metadata (targets current selection)', () => { + const r = parseOverlayImport(JSON.stringify(overlay)); + expect(r.ok).toBe(true); + expect(r.ok && r.kind).toBeUndefined(); + expect(r.ok && r.content).toEqual(overlay); + }); + + it('rejects a source-template envelope (no locale) as not a translation', () => { + const file = JSON.stringify({ name: 'horizon.layer.MESH', kind: 'layer', version: 1, content: layer }); + const r = parseOverlayImport(file); + expect(r.ok).toBe(false); + expect(!r.ok && r.error).toMatch(/source template/); + }); + + it('rejects a kind that has no translations (e.g. infra-3d)', () => { + const file = JSON.stringify({ name: 'horizon.infra-3d.config', kind: 'infra-3d', version: 1, content: {} }); + const r = parseOverlayImport(file); + expect(r.ok).toBe(false); + expect(!r.ok && r.error).toMatch(/overview \/ layer/); + }); + + it('rejects malformed JSON', () => { + const r = parseOverlayImport('nope'); + expect(r.ok).toBe(false); + }); +}); + +describe('buildOverlayExportEnvelope', () => { + it('adds the locale and the .i18n.<locale> name tail', () => { + expect(buildOverlayExportEnvelope('layer', 'horizon.layer.MESH', 'zh-CN', overlay)).toEqual({ + name: 'horizon.layer.MESH.i18n.zh-CN', + kind: 'layer', + version: 1, + locale: 'zh-CN', + content: overlay, + }); + }); +}); diff --git a/apps/ui/src/features/admin/_shared/templatePortability.ts b/apps/ui/src/features/admin/_shared/templatePortability.ts new file mode 100644 index 0000000..c466147 --- /dev/null +++ b/apps/ui/src/features/admin/_shared/templatePortability.ts @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Client-side import / export for dashboard templates (overview / layer / + * 3D-map), shared by the three admin pages. + * + * - EXPORT downloads the *in-use* version (what end users render: the + * OAP-live copy, else the bundled default) wrapped in the canonical OAP + * envelope `{ name, kind, version, content }`, so the file is + * self-describing and round-trips back through import. + * - IMPORT reads a file, validates it for the target page's `kind`, and + * returns the BARE inner content to stage as a browser-local draft. + * `localEdits` and `bff.templateSync.save` both take bare content (the + * BFF re-wraps the envelope on save), so import must unwrap. + * + * The envelope shape mirrors the BFF's `logic/templates/names.ts`; it is + * duplicated inline because UI code can't import server modules. Import + * tolerates either an envelope or a hand-authored bare-content file, and + * derives the target key from the envelope name's tail (handled by the + * caller via the returned `key`) or the content's own `id` / `key`. + */ + +import type { TemplateKind } from '@/api/scopes/configs'; + +/** Canonical OAP template envelope. `version` is the wrapper's schema + * version, not the inner content's. */ +interface TemplateEnvelope { + name: string; + kind: TemplateKind; + version: number; + content: unknown; +} + +const ENVELOPE_VERSION = 1; + +/** Wrap a template's content for export. `name` is the canonical + * `horizon.<kind>.<key>` the OAP row is keyed by. */ +export function buildExportEnvelope( + kind: TemplateKind, + name: string, + content: unknown, +): TemplateEnvelope { + return { name, kind, version: ENVELOPE_VERSION, content }; +} + +/** Trigger a browser download of `obj` as pretty-printed JSON. */ +export function downloadJson(filename: string, obj: unknown): void { + const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + a.remove(); + // Revoke after the click is committed — Safari aborts the download if the + // object URL is freed synchronously. + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +/** Open the OS file picker for a single JSON file and resolve its text. + * Resolves `null` when the operator cancels or picks nothing — a cancelled + * picker fires no `change` on most browsers, so callers must treat `null` + * as "no file" rather than awaiting forever. */ +export function pickJsonFile(): Promise<string | null> { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json,.json'; + input.style.display = 'none'; + input.addEventListener('change', () => { + const file = input.files?.[0]; + input.remove(); + if (!file) { + resolve(null); + return; + } + file.text().then(resolve).catch(() => resolve(null)); + }); + document.body.appendChild(input); + input.click(); + }); +} + +export type ImportResult = + | { ok: true; key: string; content: unknown } + | { ok: false; error: string }; + +function isObject(v: unknown): v is Record<string, unknown> { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** Human label per kind — used in cross-kind rejection messages. */ +const KIND_LABEL: Record<TemplateKind, string> = { + overview: 'overview dashboard', + layer: 'layer dashboard', + alert: 'alert page', + theme: 'theme', + 'time-defaults': 'time defaults', + 'infra-3d': '3D-map config', +}; + +/** Structural sniff + target-key derivation per kind. This is a guard + * against importing the wrong file into the wrong page, NOT full schema + * validation — the deep checks (e.g. the 3D-map zod schema) run + * server-side when the operator pushes the draft to OAP. */ +function validateContent( + kind: TemplateKind, + content: unknown, +): { ok: true; key: string } | { ok: false; error: string } { + if (!isObject(content)) { + return { ok: false, error: 'The template content is not a JSON object.' }; + } + if (kind === 'overview') { + if (typeof content.id !== 'string' || !content.id) { + return { ok: false, error: 'Not an overview dashboard — missing a string "id".' }; + } + if (!Array.isArray(content.widgets)) { + return { ok: false, error: 'Not an overview dashboard — missing a "widgets" array.' }; + } + return { ok: true, key: content.id }; + } + if (kind === 'layer') { + if (typeof content.key !== 'string' || !content.key) { + return { ok: false, error: 'Not a layer dashboard — missing a string "key".' }; + } + return { ok: true, key: content.key.toUpperCase() }; + } + if (kind === 'infra-3d') { + if (!Array.isArray(content.levels) || !isObject(content.layers) || !isObject(content.filter)) { + return { ok: false, error: 'Not a 3D-map config — missing "levels" / "layers" / "filter".' }; + } + return { ok: true, key: 'config' }; + } + return { ok: false, error: `Import is not supported for ${KIND_LABEL[kind]} templates.` }; +} + +/** Parse + validate an imported file's text for the page's `kind`. Accepts + * the export envelope or a bare-content file; returns the bare inner + * content (never the envelope) plus the derived target key. */ +export function validateImport(kind: TemplateKind, text: string): ImportResult { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { ok: false, error: 'The file is not valid JSON.' }; + } + if (!isObject(parsed)) { + return { ok: false, error: 'The file is not a JSON object.' }; + } + + // Envelope (has a `kind` discriminator + `content`): reject cross-kind + // before touching the inner content, then unwrap it. + let content: unknown = parsed; + if (typeof parsed.kind === 'string' && 'content' in parsed) { + if (parsed.kind !== kind) { + const got = KIND_LABEL[parsed.kind as TemplateKind] ?? parsed.kind; + return { + ok: false, + error: `This file is a ${got} template — open the matching admin page to import it.`, + }; + } + content = parsed.content; + } + + const v = validateContent(kind, content); + if (!v.ok) return v; + return { ok: true, key: v.key, content }; +} + +/* ── Per-locale translation overlays ────────────────────────────────── + * Translations live on the Translations page as their own OAP rows + * (`horizon.<kind>.<key>.i18n.<locale>`), separate from the source + * template. Their import/export is therefore separate too — these + * helpers mirror the template ones for the overlay shape. Only + * overview + layer templates carry overlays. */ + +/** Kinds that have translation overlays (the Translations page scope). */ +type OverlayKind = 'overview' | 'layer'; + +/** Export envelope for a per-locale overlay. Adds `locale` and the + * `.i18n.<locale>` name tail (mirrors the BFF's buildOverlayEnvelope). */ +export function buildOverlayExportEnvelope( + kind: OverlayKind, + sourceName: string, + locale: string, + content: unknown, +): TemplateEnvelope & { locale: string } { + return { name: `${sourceName}.i18n.${locale}`, kind, version: ENVELOPE_VERSION, locale, content }; +} + +export type OverlayImportResult = + | { + ok: true; + /** Present when the file is a Horizon overlay envelope — the import + * then targets THIS (template, locale). Absent for a bare overlay, + * which targets the page's current selection. */ + kind?: OverlayKind; + sourceName?: string; + locale?: string; + content: Record<string, unknown>; + } + | { ok: false; error: string }; + +/** Parse + validate an imported translation file. Accepts a Horizon + * overlay envelope (carries kind + name + locale → targets its own + * template/locale) or a bare overlay object (no metadata → the caller + * applies it to the current selection). The discriminator that proves a + * file is an *overlay* and not a source template is the presence of a + * locale (explicit field or the `.i18n.<locale>` name tail) — a source + * envelope has neither, and is rejected here. */ +export function parseOverlayImport(text: string): OverlayImportResult { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { ok: false, error: 'The file is not valid JSON.' }; + } + if (!isObject(parsed)) { + return { ok: false, error: 'The file is not a JSON object.' }; + } + if (typeof parsed.kind === 'string' && 'content' in parsed) { + const kind = parsed.kind; + if (kind !== 'overview' && kind !== 'layer') { + const got = KIND_LABEL[kind as TemplateKind] ?? kind; + return { ok: false, error: `Translations apply to overview / layer templates — this file is a ${got}.` }; + } + if (!isObject(parsed.content)) { + return { ok: false, error: 'The translation content is not a JSON object.' }; + } + let locale = typeof parsed.locale === 'string' ? parsed.locale : undefined; + let sourceName: string | undefined; + if (typeof parsed.name === 'string') { + const m = /^(.*)\.i18n\.([A-Za-z][A-Za-z0-9-]*)$/.exec(parsed.name); + if (m) { + sourceName = m[1]; + locale = locale ?? m[2]; + } else { + sourceName = parsed.name; + } + } + if (!locale) { + return { + ok: false, + error: 'This looks like a source template, not a translation — import it on its own template page.', + }; + } + return { ok: true, kind, sourceName, locale, content: parsed.content }; + } + // Bare overlay object — no metadata; target the current selection. + return { ok: true, content: parsed }; +} diff --git a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue index 4636d2c..c82fce2 100644 --- a/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue +++ b/apps/ui/src/features/admin/infra-3d/Infra3dAdminView.vue @@ -56,6 +56,7 @@ import { refresh as refreshLiveInfraConfig } from '@/features/infra-3d/composabl import { useTemplateSources } from '@/features/admin/_shared/useTemplateSources'; import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync'; import { useLocalTemplateEdits } from '@/controls/localTemplateEdits'; +import { buildExportEnvelope, downloadJson, pickJsonFile, validateImport } from '@/features/admin/_shared/templatePortability'; import { refreshConfigBundle } from '@/controls/configBundle'; import { stableStringify } from '@/utils/stableJson'; import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue'; @@ -248,6 +249,35 @@ function resetTo(src: 'bundled' | 'remote'): void { resetMenuOpen.value = false; } +// ── Import / Export ──────────────────────────────────────────────── +// Export downloads the IN-USE config (remote, else bundled) — what the map +// renders — not the editor draft. Import stages a file as a local draft; +// `loadFrom('local')` normalizes any legacy topology/load shapes. Errors +// reuse the push-issue list; success shows a transient note. +const importMsg = ref<string | null>(null); +function onExport(): void { + const inUse = sources.remote<Infra3dConfig>(NAME) ?? sources.bundled<Infra3dConfig>(NAME); + if (!inUse) return; + downloadJson(`${NAME}.json`, buildExportEnvelope('infra-3d', NAME, inUse)); +} +async function onImportFile(): Promise<void> { + const text = await pickJsonFile(); + if (text === null) return; + const res = validateImport('infra-3d', text); + if (!res.ok) { + pushErr.value = [res.error]; + importMsg.value = null; + return; + } + localEdits.set(NAME, res.content); + loadFrom('local'); + pushErr.value = null; + importMsg.value = t('Imported as a local draft — preview, then “Check diff & push”.'); + setTimeout(() => { + importMsg.value = null; + }, 6000); +} + // ── Levels editing ──────────────────────────────────────────────────── function addLevel(): void { if (!draft.value) return; @@ -605,6 +635,23 @@ const stats = computed(() => { </button> </div> </div> + <!-- Export the in-use config to a file; import a file as a local + draft. Export needs the sources loaded; bundled always exists. --> + <button + class="btn" + :disabled="!ready" + :title="t('Download the in-use config (live on OAP, or the bundled default) as a JSON file.')" + @click="onExport" + > + {{ t('Export') }} + </button> + <button + class="btn" + :title="t('Load a config JSON file as a local draft — preview, then publish.')" + @click="onImportFile" + > + {{ t('Import') }} + </button> <button class="btn" :disabled="!dirty" @click="save"> {{ hasLocalDraft && !dirty ? t('Saved local') : t('Save local') }} </button> @@ -618,6 +665,7 @@ const stats = computed(() => { <ul v-if="pushErr && pushErr.length" class="issues"> <li v-for="(it, i) in pushErr" :key="i"><code>{{ it }}</code></li> </ul> + <p v-if="importMsg" class="import-msg">{{ importMsg }}</p> <div v-if="loadError" class="loading"> {{ t('Couldn\'t load the 3D-map config — the BFF may be unreachable. Refresh the page to retry.') }} @@ -977,6 +1025,15 @@ export { parseHexColor }; overflow-y: auto; } .issues code { color: #fff; } +.import-msg { + margin: 8px 20px 0; + padding: 8px 12px; + border: 1px solid var(--sw-line-2); + border-radius: 4px; + background: var(--sw-bg-2); + font-size: 11px; + color: var(--sw-fg-2); +} .loading { padding: 20px; color: var(--sw-fg-3); font-size: 12px; } /* Sections */ diff --git a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue index 12b5c01..ea08d37 100644 --- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue +++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue @@ -49,6 +49,7 @@ type AdminScope = DashboardScope | 'networkProfiling'; import { bff, bffClient, BffApiError } from '@/api/client'; import { useLocalTemplateEdits, layerEditName } from '@/controls/localTemplateEdits'; import { useTemplateSources } from '@/features/admin/_shared/useTemplateSources'; +import { buildExportEnvelope, downloadJson, pickJsonFile, validateImport } from '@/features/admin/_shared/templatePortability'; import { usePreviewOverride } from '@/controls/previewOverride'; import TimeChart from '@/components/charts/TimeChart.vue'; import TopList from '@/components/charts/TopList.vue'; @@ -832,6 +833,50 @@ async function pushToOap(): Promise<void> { } } +// ── Import / Export ──────────────────────────────────────────────── +function flashMsg(msg: string): void { + saveMsg.value = msg; + setTimeout(() => { + if (saveMsg.value === msg) saveMsg.value = null; + }, 6000); +} +// Export downloads the IN-USE version (remote, else bundled) — what end +// users render — not the editor draft. Every shipped layer has a bundled +// default, so Export is effectively always available. +const canExport = computed<boolean>(() => remoteAvailable.value || bundledExists.value); +function onExport(): void { + const name = editName.value; + const inUse = + sources.remote<AdminLayerTemplate>(name) ?? + sources.bundled<AdminLayerTemplate>(name) ?? + templates.value.find((t) => t.key === selectedKey.value) ?? + null; + if (!inUse) return; + downloadJson(`${name}.json`, buildExportEnvelope('layer', name, inUse)); +} +// Import stages a file as a local draft for the layer the file names. Layer +// keys are a closed enum — you can't invent a layer here — so the target +// KEY must already be loaded on this deployment; otherwise reject. +async function onImportFile(): Promise<void> { + const text = await pickJsonFile(); + if (text === null) return; + const res = validateImport('layer', text); + if (!res.ok) { + flashMsg(res.error); + return; + } + const key = res.key; // already upper-cased by the validator + if (!templates.value.some((t) => t.key === key)) { + flashMsg(`Layer “${key}” is not loaded on this deployment — import is limited to layers present here.`); + return; + } + selectedKey.value = key; + selectedIdx.value = null; + localEdits.set(layerEditName(key), res.content); + loadFrom('local'); + flashMsg(`Imported “${key}” as a local draft. Preview, then “Check diff & push”.`); +} + // ── Disable / reactivate (OAP has no hard DELETE) ────────────────── // Disabling soft-disables the layer on OAP: a disabled template drops out // of the bundle AND the menu, so the layer disappears from the sidebar. @@ -1674,6 +1719,23 @@ const namingTest = computed<NamingTestResult>(() => { class="src-tag is-remote" :title="t('Showing the OAP-live version. End users render the same bytes.')" >{{ t('from remote') }}</span> + <!-- Export the in-use version to a file; import a file as a + local draft for the layer it names. --> + <button + class="sw-btn" + type="button" + :disabled="!canExport" + :title="canExport + ? 'Download the in-use version (live on OAP, or the bundled default) as a JSON file.' + : 'Nothing to export yet.'" + @click="onExport" + >Export</button> + <button + class="sw-btn" + type="button" + title="Import a layer dashboard JSON file as a local draft — preview, then publish." + @click="onImportFile" + >Import</button> <!-- Reset the editor to a source (discard current content). --> <div class="reset-dd"> <button class="sw-btn" type="button" @click="resetDropdownOpen = !resetDropdownOpen"> diff --git a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue index a71d0b7..05a5d58 100644 --- a/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue +++ b/apps/ui/src/features/admin/overview-templates/OverviewTemplatesAdmin.vue @@ -36,7 +36,7 @@ decisions, not config tweaks. --> <script setup lang="ts"> -import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue'; +import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, watchEffect } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; import { useQuery } from '@tanstack/vue-query'; @@ -50,6 +50,7 @@ import type { OverviewTemplateSummary } from '@/api/scopes/overview'; import { useLocalTemplateEdits, overviewEditName } from '@/controls/localTemplateEdits'; import { usePreviewOverride } from '@/controls/previewOverride'; import { useTemplateSources } from '@/features/admin/_shared/useTemplateSources'; +import { buildExportEnvelope, downloadJson, pickJsonFile, validateImport } from '@/features/admin/_shared/templatePortability'; import { useLayers } from '@/shell/useLayers'; import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue'; import { refreshConfigBundle } from '@/controls/configBundle'; @@ -809,6 +810,46 @@ async function pushToOap(): Promise<void> { } } +// ── Import / Export ──────────────────────────────────────────────── +// Export downloads the IN-USE version (what end users render: remote, +// else bundled) — never the editor draft. A never-published local-only +// draft has no in-use version, so Export is disabled there. +const canExport = computed<boolean>(() => remoteAvailable.value || bundledExists.value); +function onExport(): void { + if (!editName.value) return; + const inUse = + sources.remote<OverviewDashboard>(editName.value) ?? + sources.bundled<OverviewDashboard>(editName.value); + if (!inUse) return; + downloadJson(`${editName.value}.json`, buildExportEnvelope('overview', editName.value, inUse)); +} +// Import stages a file as a LOCAL draft for the dashboard the file names +// (a new id creates a new local-only draft), then selects it. Order +// matters: set the draft first so `localOnlyDrafts` sees a new id before +// the auto-select watchEffect runs, then select + force-load local so an +// unsaved-but-dirty editor doesn't suppress the seed watcher. +async function onImportFile(): Promise<void> { + const text = await pickJsonFile(); + if (text === null) return; + const res = validateImport('overview', text); + if (!res.ok) { + setFlash(res.error); + return; + } + const id = res.key; + const existed = dashboards.value.some((d) => d.id === id); + localEdits.set(overviewEditName(id), res.content); + await nextTick(); + selectedId.value = id; + selectedWidgetId.value = null; + loadFrom('local'); + setFlash( + existed + ? `Imported · overwrote the local draft “${id}”. Preview, then “Check diff & push”.` + : `Imported “${id}” as a new local draft. Preview, then “Check diff & push”.`, + ); +} + // ── KPI row helpers (kpi-tile only) ──────────────────────────────── function addKpi(w: OverviewWidget): void { const next = [...(w.kpis ?? []), { label: 'new KPI', mqe: '' } as OverviewKpi]; @@ -1113,6 +1154,24 @@ function widgetKindLabel(type: OverviewWidget['type']): string { class="ot__src is-remote" :title="t('Showing the OAP-live version. End users render the same bytes.')" >{{ t('from remote') }}</span> + <!-- Export the in-use version to a file; import a file as a + local draft. Export is disabled for a never-published + local-only draft (nothing in use to download). --> + <button + type="button" + class="ot__btn" + :disabled="!canExport" + :title="canExport + ? 'Download the in-use version (live on OAP, or the bundled default) as a JSON file.' + : 'Nothing published yet to export — push this draft first.'" + @click="onExport" + >export</button> + <button + type="button" + class="ot__btn" + title="Import a dashboard JSON file as a local draft — preview, then publish." + @click="onImportFile" + >import</button> <div class="reset-dd"> <button type="button" class="ot__btn" @click="resetDropdownOpen = !resetDropdownOpen"> reset to <span class="caret" :class="{ open: resetDropdownOpen }">›</span> diff --git a/apps/ui/src/features/admin/translations/TranslationsView.vue b/apps/ui/src/features/admin/translations/TranslationsView.vue index 4f7cc40..34f958b 100644 --- a/apps/ui/src/features/admin/translations/TranslationsView.vue +++ b/apps/ui/src/features/admin/translations/TranslationsView.vue @@ -31,9 +31,15 @@ diff modal (remote vs local) and publishes via templateSync.save. --> <script setup lang="ts"> -import { computed, ref, watch } from 'vue'; +import { computed, nextTick, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useTemplateSources } from '@/features/admin/_shared/useTemplateSources'; +import { + buildOverlayExportEnvelope, + downloadJson, + parseOverlayImport, + pickJsonFile, +} from '@/features/admin/_shared/templatePortability'; import { useTemplateSync } from '@/features/admin/_shared/useTemplateSync'; import SyncStatusBanner from '@/features/admin/_shared/SyncStatusBanner.vue'; import LayerDashboardCanvas from '@/features/admin/_shared/LayerDashboardCanvas.vue'; @@ -662,6 +668,87 @@ const readOnly = computed<boolean>(() => selectedKind.value === 'overview' ? overviewSync.readOnly.value : layerSync.readOnly.value, ); +// ── Import / Export ──────────────────────────────────────────────── +// Translations are their own OAP rows on their own page, so their +// import/export is separate from the source-template pages. Both act on +// the CURRENT (template, target locale) — the same unit Stage / Push use. +function flashMsg(msg: string): void { + saveMsg.value = msg; + setTimeout(() => { + if (saveMsg.value === msg) saveMsg.value = null; + }, 6000); +} +/** The in-use overlay for (selected template, target locale): the OAP row + * (what's published) wins, else the disk-shipped seed. A pushed row is + * already the full merged overlay, so this is the complete in-use copy. */ +const inUseOverlayForTarget = computed<Record<string, unknown> | null>(() => { + const snap = fetchedOverlays.value[overlayKey(selectedName.value, target.value)]; + const v = snap?.oap ?? snap?.disk ?? null; + return v && typeof v === 'object' ? (v as Record<string, unknown>) : null; +}); +const canExport = computed<boolean>(() => inUseOverlayForTarget.value !== null); +function onExport(): void { + const overlay = inUseOverlayForTarget.value; + const name = selectedName.value; + if (!overlay || !name) return; + downloadJson( + `${name}.i18n.${target.value}.json`, + buildOverlayExportEnvelope(selectedKind.value, name, target.value, overlay), + ); +} +// Import stages a translation file as a LOCAL draft. A Horizon overlay +// envelope targets its own (template, locale) — switching the picker to +// it; a bare overlay object goes into the current selection. +async function onImportFile(): Promise<void> { + const text = await pickJsonFile(); + if (text === null) return; + const res = parseOverlayImport(text); + if (!res.ok) { + flashMsg(t('Import failed: {error}', { error: res.error })); + return; + } + const kind = res.kind ?? selectedKind.value; + const name = res.sourceName ?? selectedName.value; + const locStr = res.locale ?? target.value; + if (locStr === 'en' || !(SUPPORTED_LOCALES as readonly string[]).includes(locStr)) { + flashMsg(t('Unsupported language: {locale}', { locale: locStr })); + return; + } + const loc = locStr as Locale; + if (res.sourceName) { + const entries = kind === 'overview' ? overviewEntries.value : layerEntries.value; + if (!entries.some((e) => e.value === name)) { + flashMsg(t('Template {name} is not loaded on this deployment.', { name })); + return; + } + selectedKind.value = kind; + selectedName.value = name; + } + target.value = loc; + await nextTick(); + const eff = effective.value; + if (!eff) { + flashMsg(t('Could not load the target template.')); + return; + } + // Overwrite the (template, locale) draft from the imported overlay, then + // stage it locally so Push publishes exactly the imported translation. + const tplMap = { ...(draft.value[name] ?? {}) }; + delete tplMap[loc]; + draft.value = { ...draft.value, [name]: tplMap }; + applyOverlayToDraft(name, loc, res.content, eff); + const overlay = buildOverlayContent(name, loc, eff); + if (overlay) localEdits.set(name, loc, overlay); + else localEdits.remove(name, loc); + editorSource.value = 'local'; + closePanel(); + flashMsg( + t('Imported {locale} translations as a local draft — review, then “Check diff & push”.', { + locale: LOCALE_NATIVE_LABEL[loc], + }), + ); +} + /** Human label for a translatable field shown next to the EN source. * The wire path (e.g. `kpis[0].label`) is internal — translators * shouldn't see it; they should see what KIND of string they're @@ -771,6 +858,23 @@ function leafLabel(segments: Array<string | number>): string { class="tv__src is-remote" :title="t('Showing the OAP-live version. End users render the same bytes.')" >{{ t('from remote') }}</span> + <!-- Export the in-use translation to a file; import a translation + file as a local draft. Both act on the current target locale. --> + <button + type="button" + class="sw-btn" + :disabled="!canExport" + :title="canExport + ? t('Download the in-use {locale} translation as a JSON file.', { locale: LOCALE_NATIVE_LABEL[target] }) + : t('No published {locale} translation to export yet.', { locale: LOCALE_NATIVE_LABEL[target] })" + @click="onExport" + >{{ t('Export') }}</button> + <button + type="button" + class="sw-btn" + :title="t('Import a translation JSON file as a local draft — review, then publish.')" + @click="onImportFile" + >{{ t('Import') }}</button> <!-- Reset to ▾ dropdown — matches the layer / overview editors. Discards local edits and re-seeds the draft from the picked source. --> diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json index 0d800f8..9066b5c 100644 --- a/apps/ui/src/i18n/locales/de.json +++ b/apps/ui/src/i18n/locales/de.json @@ -1269,5 +1269,18 @@ "The single catch-all: any layer no tier pins lands here.": "Die einzige Auffangstufe: jede Ebene, die an keine Stufe angeheftet ist, landet hier.", "Push 3D-map config to OAP": "3D-map-Konfiguration auf OAP veröffentlichen", "Left = live on OAP (remote). Right = your local draft. Pushing replaces the OAP copy — the map renders it on the next visit.": "Links = live auf OAP (Remote). Rechts = dein lokaler Entwurf. Die Veröffentlichung ersetzt die OAP-Kopie — die Karte zeigt sie beim nächsten Besuch.", - "Push to OAP": "Auf OAP veröffentlichen" + "Push to OAP": "Auf OAP veröffentlichen", + "Export": "Exportieren", + "Import": "Importieren", + "Download the in-use config (live on OAP, or the bundled default) as a JSON file.": "Lädt die aktive Konfiguration (live auf OAP, oder die mitgelieferte Vorgabe) als JSON-Datei herunter.", + "Load a config JSON file as a local draft — preview, then publish.": "Lädt eine JSON-Konfigurationsdatei als lokalen Entwurf — Vorschau, dann veröffentlichen.", + "Imported as a local draft — preview, then “Check diff & push”.": "Als lokaler Entwurf importiert — Vorschau, dann „Diff prüfen & übertragen“.", + "Download the in-use {locale} translation as a JSON file.": "Lädt die aktive {locale}-Übersetzung als JSON-Datei herunter.", + "No published {locale} translation to export yet.": "Noch keine veröffentlichte {locale}-Übersetzung zum Exportieren.", + "Import a translation JSON file as a local draft — review, then publish.": "Importiert eine Übersetzungs-JSON-Datei als lokalen Entwurf — prüfen, dann veröffentlichen.", + "Imported {locale} translations as a local draft — review, then “Check diff & push”.": "{locale}-Übersetzungen als lokaler Entwurf importiert — prüfen, dann „Diff prüfen & übertragen“.", + "Import failed: {error}": "Import fehlgeschlagen: {error}", + "Unsupported language: {locale}": "Nicht unterstützte Sprache: {locale}", + "Template {name} is not loaded on this deployment.": "Vorlage {name} ist auf dieser Instanz nicht geladen.", + "Could not load the target template.": "Die Zielvorlage konnte nicht geladen werden." } diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json index 1780696..ef9ec97 100644 --- a/apps/ui/src/i18n/locales/en.json +++ b/apps/ui/src/i18n/locales/en.json @@ -1280,5 +1280,18 @@ "The single catch-all: any layer no tier pins lands here.": "The single catch-all: any layer no tier pins lands here.", "Push 3D-map config to OAP": "Push 3D-map config to OAP", "Left = live on OAP (remote). Right = your local draft. Pushing replaces the OAP copy — the map renders it on the next visit.": "Left = live on OAP (remote). Right = your local draft. Pushing replaces the OAP copy — the map renders it on the next visit.", - "Push to OAP": "Push to OAP" + "Push to OAP": "Push to OAP", + "Export": "Export", + "Import": "Import", + "Download the in-use config (live on OAP, or the bundled default) as a JSON file.": "Download the in-use config (live on OAP, or the bundled default) as a JSON file.", + "Load a config JSON file as a local draft — preview, then publish.": "Load a config JSON file as a local draft — preview, then publish.", + "Imported as a local draft — preview, then “Check diff & push”.": "Imported as a local draft — preview, then “Check diff & push”.", + "Download the in-use {locale} translation as a JSON file.": "Download the in-use {locale} translation as a JSON file.", + "No published {locale} translation to export yet.": "No published {locale} translation to export yet.", + "Import a translation JSON file as a local draft — review, then publish.": "Import a translation JSON file as a local draft — review, then publish.", + "Imported {locale} translations as a local draft — review, then “Check diff & push”.": "Imported {locale} translations as a local draft — review, then “Check diff & push”.", + "Import failed: {error}": "Import failed: {error}", + "Unsupported language: {locale}": "Unsupported language: {locale}", + "Template {name} is not loaded on this deployment.": "Template {name} is not loaded on this deployment.", + "Could not load the target template.": "Could not load the target template." } diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json index 4df1934..28927a6 100644 --- a/apps/ui/src/i18n/locales/es.json +++ b/apps/ui/src/i18n/locales/es.json @@ -1269,5 +1269,18 @@ "The single catch-all: any layer no tier pins lands here.": "El único nivel comodín: cualquier capa que ningún nivel fije termina aquí.", "Push 3D-map config to OAP": "Publicar la configuración del 3D-map en OAP", "Left = live on OAP (remote). Right = your local draft. Pushing replaces the OAP copy — the map renders it on the next visit.": "Izquierda = en vivo en OAP (remote). Derecha = tu borrador local. El push reemplaza la copia en OAP — el mapa la mostrará en la próxima visita.", - "Push to OAP": "Publicar en OAP" + "Push to OAP": "Publicar en OAP", + "Export": "Exportar", + "Import": "Importar", + "Download the in-use config (live on OAP, or the bundled default) as a JSON file.": "Descarga la configuración en uso (la versión activa en OAP, o el valor por defecto incluido) como un archivo JSON.", + "Load a config JSON file as a local draft — preview, then publish.": "Carga un archivo JSON de configuración como borrador local — previsualiza y luego publica.", + "Imported as a local draft — preview, then “Check diff & push”.": "Importado como borrador local — previsualiza y luego «Ver diff y publicar».", + "Download the in-use {locale} translation as a JSON file.": "Descarga la traducción {locale} en uso como un archivo JSON.", + "No published {locale} translation to export yet.": "Aún no hay una traducción {locale} publicada para exportar.", + "Import a translation JSON file as a local draft — review, then publish.": "Importa un archivo JSON de traducción como borrador local — revisa y luego publica.", + "Imported {locale} translations as a local draft — review, then “Check diff & push”.": "Traducciones {locale} importadas como borrador local — revisa y luego «Ver diff y publicar».", + "Import failed: {error}": "Error al importar: {error}", + "Unsupported language: {locale}": "Idioma no admitido: {locale}", + "Template {name} is not loaded on this deployment.": "La plantilla {name} no está cargada en este despliegue.", + "Could not load the target template.": "No se pudo cargar la plantilla de destino." } diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json index d0fd337..254f381 100644 --- a/apps/ui/src/i18n/locales/fr.json +++ b/apps/ui/src/i18n/locales/fr.json @@ -1269,5 +1269,18 @@ "The single catch-all: any layer no tier pins lands here.": "Le palier fourre-tout unique : toute couche qu'aucun palier n'épingle atterrit ici.", "Push 3D-map config to OAP": "Publier la configuration de la 3D-map sur OAP", "Left = live on OAP (remote). Right = your local draft. Pushing replaces the OAP copy — the map renders it on the next visit.": "À gauche = en ligne sur OAP (Remote). À droite = votre brouillon local. La publication remplace la copie OAP — la carte l'affiche à la prochaine visite.", - "Push to OAP": "Publier sur OAP" + "Push to OAP": "Publier sur OAP", + "Export": "Exporter", + "Import": "Importer", + "Download the in-use config (live on OAP, or the bundled default) as a JSON file.": "Télécharge la configuration utilisée (la version active sur OAP, ou la valeur par défaut fournie) sous forme de fichier JSON.", + "Load a config JSON file as a local draft — preview, then publish.": "Charge un fichier JSON de configuration comme brouillon local — prévisualisez, puis publiez.", + "Imported as a local draft — preview, then “Check diff & push”.": "Importé comme brouillon local — prévisualisez, puis « Voir le diff et publier ».", + "Download the in-use {locale} translation as a JSON file.": "Télécharge la traduction {locale} utilisée sous forme de fichier JSON.", + "No published {locale} translation to export yet.": "Aucune traduction {locale} publiée à exporter pour l'instant.", + "Import a translation JSON file as a local draft — review, then publish.": "Importe un fichier JSON de traduction comme brouillon local — vérifiez, puis publiez.", + "Imported {locale} translations as a local draft — review, then “Check diff & push”.": "Traductions {locale} importées comme brouillon local — vérifiez, puis « Voir le diff et publier ».", + "Import failed: {error}": "Échec de l'import : {error}", + "Unsupported language: {locale}": "Langue non prise en charge : {locale}", + "Template {name} is not loaded on this deployment.": "Le modèle {name} n'est pas chargé sur ce déploiement.", + "Could not load the target template.": "Impossible de charger le modèle cible." } diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json index 81511ff..6629ec3 100644 --- a/apps/ui/src/i18n/locales/ja.json +++ b/apps/ui/src/i18n/locales/ja.json @@ -1269,5 +1269,18 @@ "The single catch-all: any layer no tier pins lands here.": "唯一の受け皿です。どのティアにもピン留めされていないレイヤーはここに配置されます。", "Push 3D-map config to OAP": "3D-map 設定を OAP に公開", "Left = live on OAP (remote). Right = your local draft. Pushing replaces the OAP copy — the map renders it on the next visit.": "左 = OAP 上の公開版(リモート)。右 = あなたのローカル草稿。公開すると OAP 上のコピーが置き換えられ、次回アクセス時にマップへ反映されます。", - "Push to OAP": "OAP に公開" + "Push to OAP": "OAP に公開", + "Export": "エクスポート", + "Import": "インポート", + "Download the in-use config (live on OAP, or the bundled default) as a JSON file.": "使用中の設定(OAP 上のライブ版、またはバンドルされた既定値)を JSON ファイルとしてダウンロードします。", + "Load a config JSON file as a local draft — preview, then publish.": "設定の JSON ファイルをローカルの下書きとして読み込みます — プレビューしてから公開します。", + "Imported as a local draft — preview, then “Check diff & push”.": "ローカルの下書きとしてインポートしました — プレビューしてから「差分を確認して公開」します。", + "Download the in-use {locale} translation as a JSON file.": "使用中の {locale} 翻訳を JSON ファイルとしてダウンロードします。", + "No published {locale} translation to export yet.": "エクスポートできる公開済みの {locale} 翻訳がまだありません。", + "Import a translation JSON file as a local draft — review, then publish.": "翻訳の JSON ファイルをローカルの下書きとして読み込みます — 確認してから公開します。", + "Imported {locale} translations as a local draft — review, then “Check diff & push”.": "{locale} の翻訳をローカルの下書きとしてインポートしました — 確認してから「差分を確認して公開」します。", + "Import failed: {error}": "インポートに失敗しました: {error}", + "Unsupported language: {locale}": "サポートされていない言語: {locale}", + "Template {name} is not loaded on this deployment.": "テンプレート {name} はこのデプロイメントに読み込まれていません。", + "Could not load the target template.": "対象のテンプレートを読み込めませんでした。" } diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json index 4b1f50b..069ade1 100644 --- a/apps/ui/src/i18n/locales/ko.json +++ b/apps/ui/src/i18n/locales/ko.json @@ -1269,5 +1269,18 @@ "The single catch-all: any layer no tier pins lands here.": "단일 포괄 티어입니다: 어느 티어에도 고정되지 않은 레이어가 모두 여기에 배치됩니다.", "Push 3D-map config to OAP": "3D-map 설정을 OAP에 게시", "Left = live on OAP (remote). Right = your local draft. Pushing replaces the OAP copy — the map renders it on the next visit.": "왼쪽 = OAP 라이브(원격). 오른쪽 = 내 로컬 초안. 게시하면 OAP 복사본을 교체하며, 다음 방문 시 맵이 이를 렌더링합니다.", - "Push to OAP": "OAP에 게시" + "Push to OAP": "OAP에 게시", + "Export": "내보내기", + "Import": "가져오기", + "Download the in-use config (live on OAP, or the bundled default) as a JSON file.": "사용 중인 구성(OAP의 라이브 버전 또는 번들된 기본값)을 JSON 파일로 다운로드합니다.", + "Load a config JSON file as a local draft — preview, then publish.": "구성 JSON 파일을 로컬 초안으로 불러옵니다 — 미리 본 후 게시하세요.", + "Imported as a local draft — preview, then “Check diff & push”.": "로컬 초안으로 가져왔습니다 — 미리 본 후 “차이 보고 게시”하세요.", + "Download the in-use {locale} translation as a JSON file.": "사용 중인 {locale} 번역을 JSON 파일로 다운로드합니다.", + "No published {locale} translation to export yet.": "내보낼 게시된 {locale} 번역이 아직 없습니다.", + "Import a translation JSON file as a local draft — review, then publish.": "번역 JSON 파일을 로컬 초안으로 불러옵니다 — 검토한 후 게시하세요.", + "Imported {locale} translations as a local draft — review, then “Check diff & push”.": "{locale} 번역을 로컬 초안으로 가져왔습니다 — 검토한 후 “차이 보고 게시”하세요.", + "Import failed: {error}": "가져오기 실패: {error}", + "Unsupported language: {locale}": "지원하지 않는 언어: {locale}", + "Template {name} is not loaded on this deployment.": "템플릿 {name}은(는) 이 배포에 로드되어 있지 않습니다.", + "Could not load the target template.": "대상 템플릿을 불러올 수 없습니다." } diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json index 7228384..b3514fe 100644 --- a/apps/ui/src/i18n/locales/pt.json +++ b/apps/ui/src/i18n/locales/pt.json @@ -1269,5 +1269,18 @@ "The single catch-all: any layer no tier pins lands here.": "O único nível coringa: toda camada que nenhum nível fixa cai aqui.", "Push 3D-map config to OAP": "Publicar a configuração do 3D-map no OAP", "Left = live on OAP (remote). Right = your local draft. Pushing replaces the OAP copy — the map renders it on the next visit.": "Esquerda = em produção no OAP (remote). Direita = seu rascunho local. Publicar substitui a cópia do OAP — o mapa passa a renderizá-la na próxima visita.", - "Push to OAP": "Publicar no OAP" + "Push to OAP": "Publicar no OAP", + "Export": "Exportar", + "Import": "Importar", + "Download the in-use config (live on OAP, or the bundled default) as a JSON file.": "Baixa a configuração em uso (a versão ativa no OAP, ou o padrão incluído) como um arquivo JSON.", + "Load a config JSON file as a local draft — preview, then publish.": "Carrega um arquivo JSON de configuração como rascunho local — visualize e depois publique.", + "Imported as a local draft — preview, then “Check diff & push”.": "Importado como rascunho local — visualize e depois «Ver diff e publicar».", + "Download the in-use {locale} translation as a JSON file.": "Baixa a tradução {locale} em uso como um arquivo JSON.", + "No published {locale} translation to export yet.": "Ainda não há uma tradução {locale} publicada para exportar.", + "Import a translation JSON file as a local draft — review, then publish.": "Importa um arquivo JSON de tradução como rascunho local — revise e depois publique.", + "Imported {locale} translations as a local draft — review, then “Check diff & push”.": "Traduções {locale} importadas como rascunho local — revise e depois «Ver diff e publicar».", + "Import failed: {error}": "Falha na importação: {error}", + "Unsupported language: {locale}": "Idioma não suportado: {locale}", + "Template {name} is not loaded on this deployment.": "O modelo {name} não está carregado nesta implantação.", + "Could not load the target template.": "Não foi possível carregar o modelo de destino." } diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json index 9ba66c1..83ee593 100644 --- a/apps/ui/src/i18n/locales/zh-CN.json +++ b/apps/ui/src/i18n/locales/zh-CN.json @@ -1269,5 +1269,18 @@ "The single catch-all: any layer no tier pins lands here.": "唯一的兜底层组:任何未被固定到具体层组的 layer 都会落到这里。", "Push 3D-map config to OAP": "将 3D-map 配置推送至 OAP", "Left = live on OAP (remote). Right = your local draft. Pushing replaces the OAP copy — the map renders it on the next visit.": "左侧 = OAP 上的线上版本(远端)。右侧 = 你的本地草稿。推送会替换 OAP 上的副本 —— 下次访问时地图便以其渲染。", - "Push to OAP": "推送至 OAP" + "Push to OAP": "推送至 OAP", + "Export": "导出", + "Import": "导入", + "Download the in-use config (live on OAP, or the bundled default) as a JSON file.": "将使用中的配置(OAP 上的实时版本,或内置默认值)下载为 JSON 文件。", + "Load a config JSON file as a local draft — preview, then publish.": "将配置 JSON 文件作为本地草稿载入——预览后再发布。", + "Imported as a local draft — preview, then “Check diff & push”.": "已作为本地草稿导入——预览后“查看差异并发布”。", + "Download the in-use {locale} translation as a JSON file.": "将使用中的 {locale} 翻译下载为 JSON 文件。", + "No published {locale} translation to export yet.": "尚无已发布的 {locale} 翻译可导出。", + "Import a translation JSON file as a local draft — review, then publish.": "将翻译 JSON 文件作为本地草稿载入——检查后再发布。", + "Imported {locale} translations as a local draft — review, then “Check diff & push”.": "已将 {locale} 翻译作为本地草稿导入——检查后“查看差异并发布”。", + "Import failed: {error}": "导入失败:{error}", + "Unsupported language: {locale}": "不支持的语言:{locale}", + "Template {name} is not loaded on this deployment.": "模板 {name} 未在此部署中加载。", + "Could not load the target template.": "无法加载目标模板。" } diff --git a/docs/customization/i18n.md b/docs/customization/i18n.md index 0d728a4..5474274 100644 --- a/docs/customization/i18n.md +++ b/docs/customization/i18n.md @@ -115,6 +115,12 @@ Rules: common widget vocabulary from the shared lexicon so you only need to translate your own prose. +## Import / Export + +On the **Translations** page, **Export** downloads the in-use translation for the picked template and **Target** language — the version live on OAP, or the shipped seed — as a JSON file. **Import** reads such a file and loads it as a **local draft** for that language; review it in the preview, then **Check diff & push** to publish. Import never writes OAP directly. + +Each language is its own file and its own row, so you export/import one language at a time. Source templates and their translations are edited on separate pages, so their import/export are separate too: a [template's export](/customization/overview-templates) carries the English source only — move its translations across here, per language. + ## Adding a new locale Adding `de`, `fr`, or any other locale is three steps: diff --git a/docs/customization/layer-templates.md b/docs/customization/layer-templates.md index d35adb3..1858413 100644 --- a/docs/customization/layer-templates.md +++ b/docs/customization/layer-templates.md @@ -339,6 +339,14 @@ Your work-in-progress lives **in your browser**, never on the server until you p A top banner summarizes page state — *Synced from OAP — N diverged, Y local* — and **Diverged** / **Local** filters narrow the picker. Each row shows a status chip: **synced** (bundled == OAP), **diverged** (OAP differs from bundled — OAP wins at render), **remote-only** (on OAP, no bundled default), **disabled** (deleted — see below), or **bundled** (OAP has no copy right now). +### Import / Export + +**Export** downloads the layer's **in-use version** — what end users render now (the OAP-live copy, or the bundled default when OAP has none) — as a JSON file, for backup, sharing, or moving the dashboard to another OAP. + +**Import** reads a layer-template JSON file and loads it as a **local draft** in this browser — it never writes OAP directly. Preview it, then **Check diff & push** to publish. Because layer keys are a fixed set, import targets the layer the file names (e.g. `MESH`), and that layer must already be present on this deployment; a file for a layer not loaded here, or one that isn't a valid layer template, is rejected with a message. + +Import/export covers the **source layer template** (the English authoring layer) only. Per-locale translations are stored separately in OAP and managed on the [Translations](/customization/i18n) page — they're not part of this file. A layer exported to a *different* OAP arrives with its English source only; move its translations across on the Translations page if you need them there. + ### Disabling / reactivating a layer OAP has no hard delete, so the **Disable** button next to the layer title soft-disables the layer on OAP. A disabled layer is dropped from the sidebar and renders nowhere, for everyone. diff --git a/docs/customization/overview-templates.md b/docs/customization/overview-templates.md index 6e8be11..3ff21db 100644 --- a/docs/customization/overview-templates.md +++ b/docs/customization/overview-templates.md @@ -243,6 +243,14 @@ A **+ New dashboard** form (inside the picker) creates a dashboard the same way: A top banner summarizes state — *Synced from OAP — N diverged, Y local* — with **Diverged** / **Local** filters. Status chips per row: **synced**, **diverged** (OAP wins at render), **remote-only**, **disabled** (deleted), **bundled**. +### Import / Export + +**Export** downloads the dashboard's **in-use version** — the copy end users render right now (the version live on OAP, or the bundled default when OAP has none) — as a JSON file. Use it to back up a dashboard, share it, or move it to another OAP. Export is unavailable for a brand-new local draft you haven't published yet, since nothing is in use to download. + +**Import** reads a dashboard JSON file and loads it as a **local draft** — it never writes OAP directly. After a valid import the dashboard is selected and tagged **local**; preview it, then **Check diff & push** to publish, exactly like any other edit. The file targets the dashboard it names: an existing id is replaced as a local draft, and a new id creates a new dashboard (like **+ New dashboard**, pre-filled from the file) — handy for restoring one you deleted. A file that isn't a val [...] + +Import/export covers the **source dashboard** (the English authoring layer) only. Per-locale translations are stored separately in OAP and managed on the [Translations](/customization/i18n) page — they're not part of this file. A dashboard exported to a *different* OAP arrives with its English source only; move its translations across on the Translations page if you need them there. + ### Deleting a dashboard OAP has no hard delete, so the **Delete** button next to the title soft-disables the dashboard on OAP (a disabled dashboard drops from the picker's live state, the sidebar, and the live page). A dashboard that exists only as an unpublished local draft is removed from your browser instead. Either action is confirmed in a dialog first; deletion is irreversible from the UI, but a new dashboard can always be created with the same id. diff --git a/docs/operate/infra-3d-map.md b/docs/operate/infra-3d-map.md index 95f21d7..770a9db 100644 --- a/docs/operate/infra-3d-map.md +++ b/docs/operate/infra-3d-map.md @@ -178,6 +178,13 @@ Pushed changes take effect the next time the map is opened. A **Reset** action reloads either the shipped bundled default or OAP's current version, so you can start over before saving. +**Export** downloads the map's in-use configuration — the version live on +OAP, or the bundled default when OAP has none — as a JSON file, for backup, +sharing, or moving it to another OAP. **Import** reads a configuration JSON +file and loads it as a local draft; preview it, then **Check diff & push** +to publish. Import never writes OAP directly, and a file that isn't a valid +3D-map configuration is rejected with a message. + Viewing the map needs read access (`infra-3d:read`, held by the built-in viewer role and above); editing and publishing the configuration needs `overview:write` (operators and admins by default). See
