diff --git a/.eslintrc.js b/.eslintrc.js index c108844..03319fc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,4 +16,10 @@ module.exports = { 'no-param-reassign': [2, { props: false }], // allow modifying properties of param 'import/no-cycle': 0, // Allow modules to use each other }, + overrides: [ + { + files: ['test/**/*.js'], + env: { node: true }, + }, + ], }; diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 39a04bf..8419609 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -11,4 +11,5 @@ jobs: with: node-version: 20 - run: npm ci - - run: npm run lint \ No newline at end of file + - run: npm run lint + - run: npm test diff --git a/.gitignore b/.gitignore index 9b264af..4ae72a4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ tools/node_modules/* tools/plugins/da-chat/node_modules/* run-publisher-local.sh .worktrees/ +.agents/diagrams/ +.playwright-cli/ diff --git a/.hlxignore b/.hlxignore index 54f44b6..4bdc562 100644 --- a/.hlxignore +++ b/.hlxignore @@ -6,3 +6,4 @@ package.json package-lock.json test/* ue/models/* +docs/* diff --git a/package.json b/package.json index 6fc5495..c793d51 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "version": "1.0.0", "description": "Block collection for AEM", "scripts": { + "test": "node --test test/", + "test:watch": "node --test --watch test/", "lint:js": "eslint .", "lint:fix": "eslint . --fix", "lint:css": "stylelint 'blocks/**/*.css' 'styles/*.css'", diff --git a/test/tools/apps/msm/core/config.test.js b/test/tools/apps/msm/core/config.test.js new file mode 100644 index 0000000..285fb5d --- /dev/null +++ b/test/tools/apps/msm/core/config.test.js @@ -0,0 +1,119 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + getSourceChain, + getSiteRoles, + getAllMsmSites, + buildDescendantTree, +} from '../../../../../tools/apps/msm/core/config.js'; + +// global ─ apac ─ india, japan +// ├ eu ─ france, uk +// └ na ─ canada, us +const rows = [ + { base: 'global', title: 'Global' }, + { base: 'global', satellite: 'apac', title: 'APAC' }, + { base: 'global', satellite: 'eu', title: 'EU' }, + { base: 'global', satellite: 'na', title: 'NA' }, + { base: 'apac', satellite: 'india', title: 'India' }, + { base: 'apac', satellite: 'japan', title: 'Japan' }, + { base: 'eu', satellite: 'france', title: 'France' }, + { base: 'eu', satellite: 'uk', title: 'UK' }, + { base: 'na', satellite: 'canada', title: 'Canada' }, + { base: 'na', satellite: 'us', title: 'US' }, +]; +const config = { rows }; + +describe('getSourceChain', () => { + it('walks root → parent for a leaf', () => { + const chain = getSourceChain(config, 'india'); + assert.deepEqual(chain.map((c) => c.site), ['global', 'apac']); + }); + + it('labels the root from its source-label row', () => { + const chain = getSourceChain(config, 'india'); + assert.equal(chain[0].label, 'Global'); + }); + + it('falls back to the id for intermediate sites with no source-label row', () => { + // `apac` is only ever a linked site or a source-with-linked, never a + // standalone source-label row, so its chain label is the id. + const chain = getSourceChain(config, 'india'); + assert.equal(chain[1].label, 'apac'); + }); + + it('returns an empty chain for a root site', () => { + assert.deepEqual(getSourceChain(config, 'global'), []); + }); +}); + +describe('getSiteRoles', () => { + it('marks a root as source-only with its direct linked sites', () => { + const roles = getSiteRoles(config, 'global'); + assert.ok(roles.asSource); + assert.equal(roles.asLinked, undefined); + assert.deepEqual(Object.keys(roles.asSource.linked), ['apac', 'eu', 'na']); + assert.equal(roles.asSource.linked.apac.descendantCount, 2); + }); + + it('marks a mid-tier site as both source and linked', () => { + const roles = getSiteRoles(config, 'apac'); + assert.deepEqual(Object.keys(roles.asSource.linked), ['india', 'japan']); + assert.equal(roles.asLinked.source, 'global'); + }); + + it('marks a leaf as linked-only', () => { + const roles = getSiteRoles(config, 'india'); + assert.equal(roles.asSource, undefined); + assert.equal(roles.asLinked.source, 'apac'); + }); +}); + +describe('getAllMsmSites', () => { + it('orders breadth-first, grouped under parents, with levels', () => { + const sites = getAllMsmSites(config); + assert.deepEqual( + sites.map((s) => s.site), + ['global', 'apac', 'eu', 'na', 'india', 'japan', 'france', 'uk', 'canada', 'us'], + ); + assert.deepEqual( + sites.map((s) => s.level), + [0, 1, 1, 1, 2, 2, 2, 2, 2, 2], + ); + }); +}); + +describe('buildDescendantTree', () => { + it('nests direct children at each level', () => { + const tree = buildDescendantTree(rows, 'global'); + assert.deepEqual(tree.map((n) => n.site), ['apac', 'eu', 'na']); + assert.deepEqual(tree[0].children.map((n) => n.site), ['india', 'japan']); + assert.deepEqual(tree[0].children[0].children, []); + }); + + it('returns empty for an unknown or leaf site', () => { + assert.deepEqual(buildDescendantTree(rows, 'india'), []); + assert.deepEqual(buildDescendantTree([], 'global'), []); + }); +}); + +describe('config column compatibility', () => { + // The sheet may use the new `source`/`linked` column names instead of the + // original `base`/`satellite`; readers accept either. + const newRows = [ + { source: 'global', title: 'Global' }, + { source: 'global', linked: 'apac', title: 'APAC' }, + { source: 'apac', linked: 'india', title: 'India' }, + ]; + const newConfig = { rows: newRows }; + + it('resolves roles from source/linked columns', () => { + const roles = getSiteRoles(newConfig, 'apac'); + assert.deepEqual(Object.keys(roles.asSource.linked), ['india']); + assert.equal(roles.asLinked.source, 'global'); + }); + + it('walks the source chain from source/linked columns', () => { + assert.deepEqual(getSourceChain(newConfig, 'india').map((c) => c.site), ['global', 'apac']); + }); +}); diff --git a/test/tools/apps/msm/core/source-tree.test.js b/test/tools/apps/msm/core/source-tree.test.js new file mode 100644 index 0000000..5ef0707 --- /dev/null +++ b/test/tools/apps/msm/core/source-tree.test.js @@ -0,0 +1,104 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildParentMap, + ancestorChain, + effectiveSource, + isOutOfSync, +} from '../../../../../tools/apps/msm/core/source-tree.js'; + +// Linked-site tree centered on root `global`: +// global ─ eu ─ france +// └ apac ─ india +const tree = [ + { siteId: 'eu', children: [{ siteId: 'france', children: [] }] }, + { siteId: 'apac', children: [{ siteId: 'india', children: [] }] }, +]; +const ROOT = 'global'; +const keyOf = (n) => n.siteId; + +describe('buildParentMap', () => { + it('maps each child to its parent and top-level nodes to the root', () => { + const pm = buildParentMap(tree, ROOT, keyOf); + assert.equal(pm.get('eu'), ROOT); + assert.equal(pm.get('apac'), ROOT); + assert.equal(pm.get('france'), 'eu'); + assert.equal(pm.get('india'), 'apac'); + }); + + it('honours the keyOf accessor (app keys nodes on `site`)', () => { + const appTree = [{ site: 'eu', children: [{ site: 'france', children: [] }] }]; + const pm = buildParentMap(appTree, ROOT, (n) => n.site); + assert.equal(pm.get('france'), 'eu'); + assert.equal(pm.get('eu'), ROOT); + }); +}); + +describe('ancestorChain', () => { + const pm = buildParentMap(tree, ROOT, keyOf); + + it('returns ancestors nearest-first, excluding the root sentinel', () => { + assert.deepEqual(ancestorChain('france', pm, ROOT), ['eu']); + assert.deepEqual(ancestorChain('eu', pm, ROOT), []); + }); +}); + +describe('effectiveSource', () => { + const pm = buildParentMap(tree, ROOT, keyOf); + const rootLm = '2024-01-01'; + + it('resolves to the root when no ancestor is detached', () => { + const src = effectiveSource('france', pm, () => undefined, ROOT, rootLm); + assert.deepEqual(src, { site: ROOT, lm: rootLm }); + }); + + it('resolves a direct child of the root to the root', () => { + const src = effectiveSource('eu', pm, () => undefined, ROOT, rootLm); + assert.deepEqual(src, { site: ROOT, lm: rootLm }); + }); + + // The bug ravuthu flagged: france must pull from eu, not the root, when eu + // holds a detached copy — even though france is two levels below global. + it('resolves to the nearest detached ancestor', () => { + const lookup = (s) => (s === 'eu' ? { isDetached: true, lastModified: '2024-05-05' } : undefined); + const src = effectiveSource('france', pm, lookup, ROOT, rootLm); + assert.deepEqual(src, { site: 'eu', lm: '2024-05-05' }); + }); + + it('prefers the nearest detached ancestor over a farther one', () => { + const deep = [{ siteId: 'eu', children: [{ siteId: 'france', children: [{ siteId: 'paris', children: [] }] }] }]; + const pmDeep = buildParentMap(deep, ROOT, keyOf); + const lookup = (s) => { + if (s === 'eu') return { isDetached: true, lastModified: '2024-02-02' }; + if (s === 'france') return { isDetached: true, lastModified: '2024-06-06' }; + return undefined; + }; + const src = effectiveSource('paris', pmDeep, lookup, ROOT, rootLm); + assert.deepEqual(src, { site: 'france', lm: '2024-06-06' }); + }); + + it('normalises a missing lastModified to null', () => { + const lookup = (s) => (s === 'eu' ? { isDetached: true } : undefined); + const src = effectiveSource('france', pm, lookup, ROOT, rootLm); + assert.deepEqual(src, { site: 'eu', lm: null }); + }); +}); + +describe('isOutOfSync', () => { + it('is true when the source changed after the copy, beyond the lag window', () => { + assert.equal(isOutOfSync('2024-01-01T00:00:10Z', '2024-01-01T00:00:00Z', 5000), true); + }); + + it('is false when the source is older than the copy', () => { + assert.equal(isOutOfSync('2024-01-01', '2024-02-01', 5000), false); + }); + + it('is false within the publish-lag grace window', () => { + assert.equal(isOutOfSync('2024-01-01T00:00:03Z', '2024-01-01T00:00:00Z', 5000), false); + }); + + it('is false when either timestamp is missing', () => { + assert.equal(isOutOfSync(null, '2024-01-01', 5000), false); + assert.equal(isOutOfSync('2024-01-01', null, 5000), false); + }); +}); diff --git a/test/tools/apps/msm/core/status.test.js b/test/tools/apps/msm/core/status.test.js new file mode 100644 index 0000000..0f7eeda --- /dev/null +++ b/test/tools/apps/msm/core/status.test.js @@ -0,0 +1,58 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { getStatusConfig } from '../../../../../tools/apps/msm/core/status.js'; + +describe('getStatusConfig — linked (no copy)', () => { + it('is green when live is current', () => { + assert.equal(getStatusConfig({ isDetached: false, liveState: 'current' }).tip, 'Live and current'); + }); + + it('is amber when only previewed', () => { + const cfg = getStatusConfig({ isDetached: false, liveState: 'behind', previewState: 'current' }); + assert.equal(cfg.tip, 'Previewed — not yet published to live'); + assert.equal(cfg.name, 'S2_Icon_AlertTriangle_20_N'); + }); + + it('is red when nothing is published', () => { + const cfg = getStatusConfig({ isDetached: false, liveState: 'not-published', previewState: 'not-published' }); + assert.equal(cfg.tip, 'Not published'); + assert.equal(cfg.name, 'S2_Icon_AlertDiamond_20_N'); + }); + + it('is red when the source changed and a publish is needed', () => { + const cfg = getStatusConfig({ isDetached: false, liveState: 'behind', previewState: 'behind' }); + assert.equal(cfg.tip, 'Source changed — publish needed'); + }); +}); + +describe('getStatusConfig — detached, behind source', () => { + it('is orange when live is still current', () => { + const cfg = getStatusConfig({ isDetached: true, outOfSync: true, liveState: 'current' }); + assert.equal(cfg.tip, 'Behind source — changed since last sync'); + }); + + it('is red when not current', () => { + const cfg = getStatusConfig({ isDetached: true, outOfSync: true, liveState: 'behind' }); + assert.equal(cfg.tip, 'Behind source — needs sync and publish'); + }); +}); + +describe('getStatusConfig — detached, in sync', () => { + it('is green when live is current', () => { + assert.equal(getStatusConfig({ isDetached: true, outOfSync: false, liveState: 'current' }).tip, 'Live and current'); + }); + + it('is amber when only previewed', () => { + const cfg = getStatusConfig({ + isDetached: true, outOfSync: false, liveState: 'behind', previewState: 'current', + }); + assert.equal(cfg.tip, 'Previewed — not yet published to live'); + }); + + it('is red when neither previewed nor published', () => { + const cfg = getStatusConfig({ + isDetached: true, outOfSync: false, liveState: 'behind', previewState: 'behind', + }); + assert.equal(cfg.tip, 'Not yet previewed or published'); + }); +}); diff --git a/test/tools/apps/msm/helpers/action-panel.model.test.js b/test/tools/apps/msm/helpers/action-panel.model.test.js new file mode 100644 index 0000000..9bd78ec --- /dev/null +++ b/test/tools/apps/msm/helpers/action-panel.model.test.js @@ -0,0 +1,409 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + cellKey, + findNode, + subtreeSites, + parentMap, + flattenAll, + flattenVisible, + columnState, + toggleTarget, + effectiveSource, + deriveCategory, + isOutOfSync, + scopedCells, + scopedPagesUp, + downGroups, + upGroups, + ancestorsToExpand, + matrixComplete, + sourceComplete, + planSelectionLoad, +} from '../../../../../tools/apps/msm/helpers/action-panel.model.js'; + +// Linked-site subtree centered on root `global`: +// apac ─ india, japan +// eu ─ france +const tree = [ + { + site: 'apac', + label: 'APAC', + children: [ + { site: 'india', label: 'India', children: [] }, + { site: 'japan', label: 'Japan', children: [] }, + ], + }, + { + site: 'eu', + label: 'EU', + children: [{ site: 'france', label: 'France', children: [] }], + }, +]; +const ROOT = 'global'; +const sorted = (set) => [...set].sort(); + +describe('findNode', () => { + it('finds a nested node', () => { + assert.equal(findNode(tree, 'india').label, 'India'); + }); + it('returns null for an unknown site', () => { + assert.equal(findNode(tree, 'nope'), null); + }); +}); + +describe('subtreeSites', () => { + it('returns a site and all its descendants', () => { + assert.deepEqual(subtreeSites(tree, 'apac'), ['apac', 'india', 'japan']); + }); + it('returns just the site for a leaf', () => { + assert.deepEqual(subtreeSites(tree, 'india'), ['india']); + }); + it('falls back to [site] when not in the tree', () => { + assert.deepEqual(subtreeSites(tree, 'missing'), ['missing']); + }); +}); + +describe('parentMap', () => { + it('maps each site to its parent, top-level to root', () => { + const pm = parentMap(tree, ROOT); + assert.equal(pm.get('apac'), 'global'); + assert.equal(pm.get('eu'), 'global'); + assert.equal(pm.get('india'), 'apac'); + assert.equal(pm.get('japan'), 'apac'); + assert.equal(pm.get('france'), 'eu'); + }); +}); + +describe('flattenAll', () => { + it('lists every column depth-first with depth and childCount', () => { + const cols = flattenAll(tree, ROOT); + assert.deepEqual(cols.map((c) => c.site), ['apac', 'india', 'japan', 'eu', 'france']); + assert.deepEqual(cols.map((c) => c.depth), [0, 1, 1, 0, 1]); + assert.equal(cols.find((c) => c.site === 'apac').childCount, 2); + assert.equal(cols.find((c) => c.site === 'india').childCount, 0); + }); +}); + +describe('flattenVisible', () => { + it('hides children of collapsed columns', () => { + const cols = flattenVisible(tree, ROOT, new Set()); + assert.deepEqual(cols.map((c) => c.site), ['apac', 'eu']); + }); + it('reveals children of expanded columns', () => { + const cols = flattenVisible(tree, ROOT, new Set(['apac'])); + assert.deepEqual(cols.map((c) => c.site), ['apac', 'india', 'japan', 'eu']); + }); +}); + +describe('columnState', () => { + const all = new Set(['apac', 'india', 'japan', 'eu', 'france']); + it('is checked when the whole subtree is included', () => { + assert.equal(columnState(tree, 'apac', all), 'checked'); + }); + it('is indeterminate when only part of the subtree is included', () => { + assert.equal(columnState(tree, 'apac', new Set(['india'])), 'indeterminate'); + }); + it('is unchecked when none of the subtree is included', () => { + assert.equal(columnState(tree, 'apac', new Set()), 'unchecked'); + }); +}); + +describe('toggleTarget', () => { + it('removes the whole subtree when unchecking', () => { + const all = new Set(['apac', 'india', 'japan', 'eu', 'france']); + const next = toggleTarget(tree, all, 'apac', ROOT); + assert.deepEqual(sorted(next), ['eu', 'france']); + }); + it('adds the subtree and re-enables ancestors when checking', () => { + const next = toggleTarget(tree, new Set(['eu', 'france']), 'india', ROOT); + assert.deepEqual(sorted(next), ['apac', 'eu', 'france', 'india']); + }); + it('checks the whole subtree when toggling an indeterminate parent', () => { + // apac is indeterminate: india included, japan not. Clicking apac fills it + // in (checks all) rather than wiping it out. + const next = toggleTarget(tree, new Set(['apac', 'india', 'eu', 'france']), 'apac', ROOT); + assert.deepEqual(sorted(next), ['apac', 'eu', 'france', 'india', 'japan']); + }); +}); + +describe('effectiveSource', () => { + const page = { path: '/p', lastModified: '2024-01-01T00:00:00Z' }; + const pm = parentMap(tree, ROOT); + + it('resolves to the root when no ancestor has a local copy', () => { + const src = effectiveSource(page, 'india', pm, new Map(), ROOT); + assert.deepEqual(src, { site: 'global', lm: '2024-01-01T00:00:00Z' }); + }); + + it('resolves to the nearest ancestor holding a detached copy', () => { + const cells = new Map([[cellKey('/p', 'apac'), { isDetached: true, lastModified: '2024-05-05' }]]); + const src = effectiveSource(page, 'india', pm, cells, ROOT); + assert.deepEqual(src, { site: 'apac', lm: '2024-05-05' }); + }); + + it('skips ancestors without a detached copy', () => { + const src = effectiveSource(page, 'france', pm, new Map(), ROOT); + assert.equal(src.site, 'global'); + }); +}); + +describe('ancestorsToExpand', () => { + it('returns the ancestors needed to reveal a nested site, excluding it and root', () => { + assert.deepEqual(sorted(ancestorsToExpand(tree, ROOT, ['india'])), ['apac']); + }); + it('needs no expansion for a top-level site', () => { + assert.deepEqual([...ancestorsToExpand(tree, ROOT, ['apac'])], []); + }); + it('unions ancestors across several affected sites', () => { + assert.deepEqual(sorted(ancestorsToExpand(tree, ROOT, ['india', 'france'])), ['apac', 'eu']); + }); + it('is empty for no affected sites', () => { + assert.deepEqual([...ancestorsToExpand(tree, ROOT, [])], []); + }); +}); + +describe('deriveCategory', () => { + it('is linked with no copy', () => { + assert.equal(deriveCategory({ isDetached: false }), 'linked'); + }); + it('is detached with a copy and an existing source', () => { + assert.equal(deriveCategory({ isDetached: true, sourceExists: true }), 'detached'); + }); + it('is local with a copy but no source above', () => { + assert.equal(deriveCategory({ isDetached: true, sourceExists: false }), 'local'); + }); +}); + +describe('isOutOfSync', () => { + it('is true when the source is newer than the copy beyond the lag', () => { + assert.equal(isOutOfSync('2024-02-01', '2024-01-01', 5000), true); + }); + it('is false when the copy is newer than the source', () => { + assert.equal(isOutOfSync('2024-01-01', '2024-02-01', 5000), false); + }); + it('is false when either timestamp is missing', () => { + assert.equal(isOutOfSync(null, '2024-01-01', 5000), false); + }); + it('absorbs differences within the publish-lag window', () => { + assert.equal(isOutOfSync('2024-01-01T00:00:03Z', '2024-01-01T00:00:00Z', 5000), false); + assert.equal(isOutOfSync('2024-01-01T00:00:10Z', '2024-01-01T00:00:00Z', 5000), true); + }); +}); + +describe('scopedCells', () => { + const allColumns = flattenAll(tree, ROOT); + const included = new Set(['apac', 'india', 'japan', 'eu', 'france']); + const pages = [{ path: '/p' }]; + const cells = new Map([ + [cellKey('/p', 'apac'), { isDetached: true }], + [cellKey('/p', 'india'), { isDetached: false }], + [cellKey('/p', 'japan'), { isDetached: false }], + [cellKey('/p', 'france'), { isDetached: false }], + // no eu cell — should be skipped + ]); + + it('selects linked cells (no copy)', () => { + const cellsOut = scopedCells(allColumns, pages, included, cells, 'linked'); + assert.deepEqual(cellsOut.map((c) => c.targetSite).sort(), ['france', 'india', 'japan']); + }); + + it('selects detached cells (has copy)', () => { + const cellsOut = scopedCells(allColumns, pages, included, cells, 'detached'); + assert.deepEqual(cellsOut.map((c) => c.targetSite), ['apac']); + }); + + it('ignores excluded targets', () => { + const cellsOut = scopedCells(allColumns, pages, new Set(['india']), cells, 'linked'); + assert.deepEqual(cellsOut.map((c) => c.targetSite), ['india']); + }); +}); + +describe('scopedPagesUp', () => { + const pages = [{ path: '/a' }, { path: '/b' }, { path: '/c' }]; + const rows = new Map([ + ['/a', { category: 'linked' }], + ['/b', { category: 'detached' }], + ['/c', { category: 'local' }], + ]); + it('filters pages by link category', () => { + assert.deepEqual(scopedPagesUp(pages, rows, 'linked').map((p) => p.path), ['/a']); + assert.deepEqual(scopedPagesUp(pages, rows, 'detached').map((p) => p.path), ['/b']); + }); +}); + +describe('downGroups', () => { + it('groups in-scope cells by (target, resolved source)', () => { + const pages = [{ path: '/p' }]; + const included = new Set(['apac', 'india', 'japan', 'eu', 'france']); + const cells = new Map([ + [cellKey('/p', 'apac'), { isDetached: true, lastModified: '2024-05-05' }], + [cellKey('/p', 'india'), { isDetached: false }], + [cellKey('/p', 'japan'), { isDetached: false }], + [cellKey('/p', 'france'), { isDetached: false }], + ]); + const groups = downGroups({ + tree, pages, allColumns: flattenAll(tree, ROOT), included, cells, rootSite: ROOT, scope: 'linked', + }); + const byTarget = Object.fromEntries(groups.map((g) => [g.target, g])); + // india/japan link through apac's detached copy; france falls back to the root. + assert.equal(byTarget.india.source, 'apac'); + assert.equal(byTarget.japan.source, 'apac'); + assert.equal(byTarget.france.source, 'global'); + assert.equal(groups.length, 3); + groups.forEach((g) => assert.equal(g.pages.length, 1)); + }); +}); + +describe('upGroups', () => { + it('groups pages by their resolved source, target is the current site', () => { + const pages = [{ path: '/a' }, { path: '/b' }, { path: '/c' }]; + const rows = new Map([ + ['/a', { category: 'linked', source: 'global' }], + ['/b', { category: 'linked', source: 'na' }], + ['/c', { category: 'linked' }], // no source — falls back to root source + ]); + const groups = upGroups({ + pages, rows, scope: 'linked', base: 'global', target: 'apac', + }); + const bySource = Object.fromEntries(groups.map((g) => [g.source, g])); + assert.deepEqual(Object.keys(bySource).sort(), ['global', 'na']); + assert.equal(bySource.global.pages.length, 2); // /a + /c (fallback) + assert.equal(bySource.na.pages.length, 1); + groups.forEach((g) => assert.equal(g.target, 'apac')); + }); +}); + +describe('matrixComplete', () => { + const allColumns = flattenAll(tree, ROOT); // apac, india, japan, eu, france + const pages = [{ path: '/a' }, { path: '/b' }]; + const full = () => { + const m = new Map(); + pages.forEach((p) => allColumns.forEach((c) => m.set(cellKey(p.path, c.site), {}))); + return m; + }; + + it('is true when every page has a cell for every column', () => { + assert.equal(matrixComplete(pages, allColumns, full()), true); + }); + it('is false when any cell is missing', () => { + const cells = full(); + cells.delete(cellKey('/b', 'france')); + assert.equal(matrixComplete(pages, allColumns, cells), false); + }); + it('is vacuously true with no pages', () => { + assert.equal(matrixComplete([], allColumns, new Map()), true); + }); +}); + +describe('sourceComplete', () => { + const pages = [{ path: '/a' }, { path: '/b' }]; + it('is true when every page has a row', () => { + assert.equal(sourceComplete(pages, new Map([['/a', {}], ['/b', {}]])), true); + }); + it('is false when a row is missing', () => { + assert.equal(sourceComplete(pages, new Map([['/a', {}]])), false); + }); +}); + +describe('planSelectionLoad', () => { + const base = { + prevContextKey: 'org|apac', + contextKey: 'org|apac', + loadedDownPaths: new Set(), + loadedUpPaths: new Set(), + hasSource: true, + hasLinked: false, + }; + const paths = (list) => list.map((p) => p.path); + + it('is a noop when context and selection are unchanged', () => { + const plan = planSelectionLoad({ + ...base, prevSelKey: '/a', selKey: '/a', pages: [{ path: '/a' }], + }); + assert.equal(plan.kind, 'noop'); + assert.deepEqual(plan.downPages, []); + assert.deepEqual(plan.upPages, []); + }); + + it('resets and loads all selected pages when the site changes', () => { + const plan = planSelectionLoad({ + ...base, + prevContextKey: 'org|global', + prevSelKey: '/x', + selKey: '/a,/b', + pages: [{ path: '/a' }, { path: '/b' }], + // even pre-loaded paths reload on a context change + loadedDownPaths: new Set(['/a']), + }); + assert.equal(plan.kind, 'reset'); + assert.deepEqual(paths(plan.downPages), ['/a', '/b']); + }); + + it('loads only newly-added pages on a selection change', () => { + const plan = planSelectionLoad({ + ...base, + prevSelKey: '/a', + selKey: '/a,/b', + pages: [{ path: '/a' }, { path: '/b' }], + loadedDownPaths: new Set(['/a']), + }); + assert.equal(plan.kind, 'incremental'); + assert.deepEqual(paths(plan.downPages), ['/b']); + }); + + it('loads nothing when a page is removed (kept data, no refetch)', () => { + const plan = planSelectionLoad({ + ...base, + prevSelKey: '/a,/b', + selKey: '/a', + pages: [{ path: '/a' }], + loadedDownPaths: new Set(['/a', '/b']), + }); + assert.equal(plan.kind, 'incremental'); + assert.deepEqual(plan.downPages, []); + }); + + it('loads nothing when re-adding a previously-loaded page', () => { + const plan = planSelectionLoad({ + ...base, + prevSelKey: '/a', + selKey: '/a,/b', + pages: [{ path: '/a' }, { path: '/b' }], + // /b was removed earlier but its data was kept + loadedDownPaths: new Set(['/a', '/b']), + }); + assert.equal(plan.kind, 'incremental'); + assert.deepEqual(plan.downPages, []); + }); + + it('filters down and up views independently for a dual site', () => { + const plan = planSelectionLoad({ + ...base, + hasLinked: true, + prevSelKey: '/a', + selKey: '/a,/b', + pages: [{ path: '/a' }, { path: '/b' }], + loadedDownPaths: new Set(['/a']), + loadedUpPaths: new Set(['/a', '/b']), + }); + assert.equal(plan.kind, 'incremental'); + assert.deepEqual(paths(plan.downPages), ['/b']); // /b missing downstream + assert.deepEqual(plan.upPages, []); // both already loaded upstream + }); + + it('skips a view whose role is absent', () => { + const plan = planSelectionLoad({ + ...base, + hasSource: false, + hasLinked: true, + prevContextKey: 'org|global', + prevSelKey: '', + selKey: '/a', + pages: [{ path: '/a' }], + }); + assert.equal(plan.kind, 'reset'); + assert.deepEqual(plan.downPages, []); // not a base → no matrix load + assert.deepEqual(paths(plan.upPages), ['/a']); + }); +}); diff --git a/tools/apps/msm/README.md b/tools/apps/msm/README.md new file mode 100644 index 0000000..76e0719 --- /dev/null +++ b/tools/apps/msm/README.md @@ -0,0 +1,126 @@ +# MSM — Multi-Site Management + +A DA (Document Authoring) tool for managing content links across source sites and the sites that link to them. MSM lets authors preview, publish, sync, and control independent copies between a source site and its linked sites from a single interface. + +## Overview + +In an AEM Edge Delivery Services multi-site setup, a **source site** holds the canonical content while **linked sites** pull from it. A linked site either stays linked (resolving content from its source) or holds its own detached copy. MSM provides a UI to manage this relationship — browsing content, checking link status, and executing bulk actions across linked sites. + +## How It Works + +### Roles + +A site's role is relative to a relationship, so the UI names the two directions rather than labelling a site absolutely: + +- **Linked sites view** — the sites that link to the current site (looking down). Actions target those sites for the selected pages. +- **Source view** — the single site the current site links up to (looking up). Actions apply between the current site and its source. + +A site that is both (a mid-tier site) shows both via tabs. The role is determined automatically from the org's MSM configuration and the site entered in the toolbar. + +### Link state + +Each page on a linked site is either: + +- **Linked** — no copy exists on the site; content resolves from its source (at preview/publish time). +- **Detached** — the site holds its own independent copy of the page, so it no longer follows the source. + +Actions are scoped by this state. For example, Publish targets linked pages, while Merge/Replace target detached copies. + +### Actions + +Terminology matches the [MSM plugin](../../plugins/msm/README.md). + +| Action | Scope | Description | +|---|---|---| +| **Preview** | Linked | Triggers a preview of the page on linked sites | +| **Publish** | Linked | Publishes the page to linked sites (live) | +| **Detach** | Linked | Creates an independent copy on the site, breaking the link | +| **Merge** | Detached | Updates the detached copy from source, keeping local edits (3-way merge) | +| **Replace** | Detached | Overwrites the detached copy with the source's content | +| **Reconnect** | Detached | Deletes the independent copy so the page links to its source again | + +Confirmations name the destination, e.g. *"Publish 6 pages to 2 linked sites: India, Japan?"*. + +## Configuration + +MSM is configured at the **org level** via the DA Admin config API (`/config/{org}/`). The config must include an `msm` property with a `data` array of rows. Columns may use either the original `base`/`satellite` names or the new `source`/`linked` names — the app reads whichever is present: + +| Column | Required | Description | +|---|---|---| +| `source` (or `base`) | Yes | The source site/repo name | +| `linked` (or `satellite`) | No | A linked site name. Rows without it define the source-site label. | +| `title` | No | Display label for the source or linked site | + +A source site must have at least one linked site to appear in the app. + +**Example config rows:** + +| source | linked | title | +|---|---|---| +| `en` | | English (Source) | +| `en` | `fr` | French | +| `en` | `de` | German | + +## Architecture + +### Files + +``` +tools/apps/msm/ +├── msm.html # Entry point (DA tool shell page) +├── msm.js # Root Lit component (MsmApp) +├── msm.css # Shell and layout styles +├── core/ # Shared MSM logic (also used by the plugin) +│ ├── config.js # Org config + link graph (source/linked) +│ ├── status.js # Page timestamp + publish status + status config +│ ├── operations.js # preview/publish/copy/delete/merge primitives +│ ├── source-tree.js # Shared source resolution (nearest detached ancestor) +│ ├── icons.js # Inline SVG icon helper +│ ├── img/ # S2 icon SVGs referenced via +│ └── fetch.js # Pluggable daFetch + constants +└── helpers/ + ├── api.js # App-facing API facade (folder listing, bulk exec) + ├── column-browser.js # Finder-style multi-column content browser + ├── column-browser.css # Column browser styles + ├── action-panel.js # Action selection, execution, and progress UI + ├── action-panel.model.js # Pure decision logic behind the action panel + └── action-panel.css # Action panel styles +``` + +### Components + +- **`msm-app`** (`msm.js`) — Root component. Manages state (org, site, config), loads MSM configuration, and coordinates the browser and action panel. +- **`msm-column-browser`** (`column-browser.js`) — A Finder-style multi-column browser for navigating site content. Lists all sites, then navigates into a site's content tree (merging in linked content where applicable). Supports folder expansion, checkbox selection, keyboard navigation, and recursive folder selection. +- **`msm-action-panel`** (`action-panel.js`) — Displays available actions for the active view and selection. Supports the linked-sites matrix and source table, linked-site filtering, sync mode selection (Merge vs Replace), confirmation dialogs for destructive actions, and per-item progress tracking. + +### External Dependencies + +| Dependency | Source | Purpose | +|---|---|---| +| Lit (via `da-lit`) | `/tools/deps/lit/dist/index.js` | Web component framework | +| DA SDK | `https://da.live/nx/utils/sdk.js` | Provides auth context and token | +| DA Fetch | `https://da.live/nx/utils/daFetch.js` | Authenticated requests to DA APIs | +| NX Merge | `https://da.live/nx/blocks/loc/project/index.js` | `mergeCopy` for content merging | +| Shoelace components | `https://da.live/nx/public/sl/components.js` | UI primitives (`sl-input`, `sl-button`, etc.) | +| NX Styles | `https://da.live/nx/styles/` and `https://da.live/nx/public/sl/styles.css` | Shared design tokens and component styles | + +### APIs + +| Endpoint | Host | Methods | Purpose | +|---|---|---|---| +| `/config/{org}/` | `admin.da.live` | GET | Fetch org config including MSM rows | +| `/list/{org}/{site}{path}` | `admin.da.live` | GET | List folder contents | +| `/source/{org}/{site}{path}` | `admin.da.live` | HEAD, GET, PUT, DELETE | Check copies, read/write/delete content | +| `/preview/{org}/{site}/main{path}` | `admin.hlx.page` | POST | Trigger preview | +| `/live/{org}/{site}/main{path}` | `admin.hlx.page` | POST | Trigger publish | +| `/status/{org}/{site}/main{path}` | `admin.hlx.page` | GET | Check preview/live status | + +Bulk operations run with a concurrency limit of 5 parallel requests. + +## Usage + +1. Open the MSM tool from the DA interface (hosted at `/tools/apps/msm/msm`). +2. Enter an org (e.g., `/myorg`) or org + site (e.g., `/myorg/en`) in the toolbar and click **Load**. +3. Browse the content tree using the column browser. Select pages or folders. +4. Choose an action from the action panel, configure options (sync mode, linked-site filter), and execute. +5. Monitor progress in the progress panel — each page/site combination shows pending, success, or error status. diff --git a/tools/apps/msm/core/config.js b/tools/apps/msm/core/config.js new file mode 100644 index 0000000..2a4d18b --- /dev/null +++ b/tools/apps/msm/core/config.js @@ -0,0 +1,302 @@ +/* eslint-disable import/no-unresolved */ +import { daFetch, DA_ORIGIN } from './fetch.js'; + +// The authored config sheet may use either the original `base`/`satellite` +// column names or the new `source`/`linked` names. These accessors read +// whichever is present, so the sheet can be migrated to the new vocabulary +// without a code change (and mixed rows still work during a migration). +const sourceOf = (row) => row.base ?? row.source; +const linkedOf = (row) => row.satellite ?? row.linked; +const hasGraphCols = (rows) => rows.length > 0 && sourceOf(rows[0]) !== undefined; + +// ────────────────────────────────────────────── +// Org config fetching + caching +// ────────────────────────────────────────────── + +const orgConfigPromises = {}; +const msmConfigCache = {}; +const siteConfigCache = {}; + +export function clearMsmCache() { + [orgConfigPromises, msmConfigCache, siteConfigCache].forEach((cache) => { + Object.keys(cache).forEach((k) => { delete cache[k]; }); + }); +} + +function fetchOrgConfig(org) { + if (!org) return Promise.resolve(null); + orgConfigPromises[org] ??= (async () => { + // no-store so a fresh Load (which clears the JS cache) actually re-reads + // the sheet rather than a heuristically-cached response. + const resp = await daFetch(`${DA_ORIGIN}/config/${org}/`, { cache: 'no-store' }); + if (!resp.ok) return null; + return resp.json(); + })(); + return orgConfigPromises[org]; +} + +export async function fetchOrgMsmRows(org) { + const orgConfig = await fetchOrgConfig(org); + return orgConfig?.msm?.data || []; +} + +// ────────────────────────────────────────────── +// Link graph helpers (operate on raw rows) +// ────────────────────────────────────────────── + +function getDirectChildren(rows, site) { + return rows + .filter((row) => sourceOf(row) === site && linkedOf(row)) + .map((row) => ({ site: linkedOf(row), label: row.title || linkedOf(row) })); +} + +function getParentRow(rows, site) { + return rows.find((row) => linkedOf(row) === site); +} + +function getSourceLabel(rows, site) { + const labelRow = rows.find((row) => sourceOf(row) === site && !linkedOf(row)); + return labelRow?.title; +} + +function walkSubtree(rows, rootSite, visited = new Set()) { + if (visited.has(rootSite)) return []; + visited.add(rootSite); + return getDirectChildren(rows, rootSite).flatMap((child) => [ + child, + ...walkSubtree(rows, child.site, visited), + ]); +} + +function walkChain(rows, site) { + const chain = []; + const visited = new Set(); + let current = site; + while (current && !visited.has(current)) { + visited.add(current); + const parentRow = getParentRow(rows, current); + if (!parentRow) break; + const parentSite = sourceOf(parentRow); + chain.unshift({ + site: parentSite, + label: getSourceLabel(rows, parentSite) || parentSite, + }); + current = parentSite; + } + return chain; +} + +/** Nested linked-site tree (direct children at each level) for the publish UI. */ +export function buildDescendantTree(rows, rootSite, visited = new Set()) { + if (!rows?.length || visited.has(rootSite)) return []; + visited.add(rootSite); + return getDirectChildren(rows, rootSite).map((child) => ({ + site: child.site, + label: child.label, + children: buildDescendantTree(rows, child.site, new Set(visited)), + })); +} + +// Dialog-facing tree shape: uses `siteId` field (matches plugin/msm/msm.js usage). +function buildDialogTree(rows, siteId) { + return getDirectChildren(rows, siteId).map((child) => ({ + siteId: child.site, + label: child.label, + children: buildDialogTree(rows, child.site), + })); +} + +export function getSourceChain(config, site) { + return walkChain(config?.rows || [], site); +} + +export function getSiteRoles(config, site) { + const rows = config?.rows || []; + const children = getDirectChildren(rows, site); + const parentRow = getParentRow(rows, site); + const result = {}; + if (children.length) { + const linked = children.reduce((acc, child) => { + acc[child.site] = { + label: child.label, + descendantCount: walkSubtree(rows, child.site).length, + descendants: buildDescendantTree(rows, child.site), + }; + return acc; + }, {}); + result.asSource = { sourceLabel: getSourceLabel(rows, site), linked }; + } + if (parentRow) { + const parentSite = sourceOf(parentRow); + result.asLinked = { + source: parentSite, + sourceLabel: getSourceLabel(rows, parentSite) || parentSite, + chain: walkChain(rows, site), + }; + } + return result; +} + +// ────────────────────────────────────────────── +// Derived config shapes +// ────────────────────────────────────────────── + +function resolveSourceSites(rows) { + if (!hasGraphCols(rows)) return []; + + const sourceSites = rows + .filter((row) => sourceOf(row)) + .reduce((acc, row) => { + const source = sourceOf(row); + const linked = linkedOf(row); + if (!acc.has(source)) { + acc.set(source, { site: source, label: '', linked: {} }); + } + const entry = acc.get(source); + if (!linked) entry.label = row.title || source; + else entry.linked[linked] = { label: row.title || linked }; + return acc; + }, new Map()); + + return [...sourceSites.values()].filter((b) => Object.keys(b.linked).length > 0); +} + +/** App-facing config: `{ sourceSites, rows }`, cached per org. */ +export async function fetchMsmConfig(org) { + if (msmConfigCache[org]) return msmConfigCache[org]; + const rows = await fetchOrgMsmRows(org); + if (!rows.length) return null; + const sourceSites = resolveSourceSites(rows); + if (!sourceSites.length) return null; + const config = { sourceSites, rows }; + msmConfigCache[org] = config; + return config; +} + +// Flat, de-duplicated list of every site referenced in the org's MSM config — +// both sources and linked sites. Each entry carries its link `level` +// (0 = root source, 1 = its direct linked sites, …). +// +// Ordering is breadth-first but grouped under parents: all of one level before +// the next, and within a level the sites are ordered by their parent's order +// in the level above. e.g. global / apac,eu,na / india,japan,france,uk,canada,us. +// Achieved by giving each site a path of sibling-indices from its root and +// sorting by (level, path). +export function getAllMsmSites(config) { + const rows = config?.rows || []; + const map = new Map(); + const add = (site, label) => { + if (!site) return; + const existing = map.get(site); + if (!existing) map.set(site, { site, label: label || site }); + else if (label && existing.label === site) existing.label = label; + }; + rows.forEach((row) => { + const source = sourceOf(row); + const linked = linkedOf(row); + if (source && !linked) add(source, row.title); + if (source && linked) { + add(source); + add(linked, row.title); + } + }); + + const paths = new Map(); + const visited = new Set(); + const assign = (site, prefix) => { + if (visited.has(site)) return; + visited.add(site); + paths.set(site, prefix); + getDirectChildren(rows, site) + .sort((a, b) => a.label.localeCompare(b.label)) + .forEach((child, i) => assign(child.site, [...prefix, i])); + }; + [...map.values()] + .filter((s) => !getParentRow(rows, s.site)) + .sort((a, b) => a.label.localeCompare(b.label)) + .forEach((root, i) => assign(root.site, [i])); + + const comparePath = (a, b) => { + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i += 1) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return a.length - b.length; + }; + const orphan = [Number.MAX_SAFE_INTEGER]; + + return [...map.values()] + .map((s) => ({ ...s, level: (paths.get(s.site) || orphan).length - 1 })) + .sort((a, b) => (a.level - b.level) + || comparePath(paths.get(a.site) || orphan, paths.get(b.site) || orphan)); +} + +// ────────────────────────────────────────────── +// Dialog-facing config: `{ asSource, asLinked }` +// ────────────────────────────────────────────── + +function resolveConfig(rows, site) { + if (!rows.length || !hasGraphCols(rows)) return null; + const directChildren = getDirectChildren(rows, site); + const parentRow = getParentRow(rows, site); + if (!directChildren.length && !parentRow) return null; + + const result = {}; + if (directChildren.length) { + const linked = directChildren.reduce((acc, child) => { + acc[child.site] = { + label: child.label, + descendantCount: walkSubtree(rows, child.site).length, + }; + return acc; + }, {}); + result.asSource = { sourceLabel: getSourceLabel(rows, site), linked }; + } + if (parentRow) { + const parentSite = sourceOf(parentRow); + result.asLinked = { + source: parentSite, + sourceLabel: getSourceLabel(rows, parentSite) || parentSite, + chain: walkChain(rows, site), + }; + } + return result; +} + +async function fetchSiteConfig(org, site) { + const key = `${org}/${site}`; + if (siteConfigCache[key]) return siteConfigCache[key]; + const rows = await fetchOrgMsmRows(org); + if (!rows.length) return null; + const config = resolveConfig(rows, site); + if (!config) return null; + siteConfigCache[key] = { config, rows }; + return siteConfigCache[key]; +} + +export async function getSiteConfig(org, site) { + const entry = await fetchSiteConfig(org, site); + return entry?.config || null; +} + +export async function getLinkedTree(org, site) { + const entry = await fetchSiteConfig(org, site); + if (!entry) return []; + return buildDialogTree(entry.rows, site); +} + +export async function getSubtreeLinked(org, sourceSite) { + const entry = await fetchSiteConfig(org, sourceSite); + if (!entry) return []; + return walkSubtree(entry.rows, sourceSite); +} + +export async function getLinkedSites(org, sourceSite) { + const config = await getSiteConfig(org, sourceSite); + return config?.asSource?.linked || {}; +} + +export async function getSourceSite(org, site) { + const config = await getSiteConfig(org, site); + return config?.asLinked?.source || null; +} diff --git a/tools/apps/msm/core/fetch.js b/tools/apps/msm/core/fetch.js new file mode 100644 index 0000000..3670bf4 --- /dev/null +++ b/tools/apps/msm/core/fetch.js @@ -0,0 +1,37 @@ +/* eslint-disable import/no-unresolved */ + +// Shared authenticated-fetch shim for MSM core. +// +// Two consumers, two fetch sources: +// - The MSM app runs on da.live and can import da.live's `daFetch` directly. +// It needs no setup — the lazy default below loads it on first use. +// - The MSM dialog runs in a cross-origin iframe and must route requests +// through the host-provided `actions.daFetch`. It calls `setDaFetch` once +// during init to inject that function. +let daFetchFn = null; + +export function setDaFetch(fn) { + daFetchFn = fn; +} + +export async function daFetch(url, opts) { + if (!daFetchFn) { + const { daFetch: fn } = await import('https://da.live/nx/utils/daFetch.js'); + daFetchFn = fn; + } + return daFetchFn(url, opts); +} + +export const DA_ORIGIN = 'https://admin.da.live'; +export const AEM_ADMIN = 'https://admin.hlx.page'; + +// Publishing bumps a page's lastModified after its publish timestamp is +// recorded, producing a spurious "behind source" signal. This absorbs that lag. +export const PUBLISH_LAG_MS = 5000; + +// Normalize a page path for use in API URLs: add leading slash, strip the +// given extension. Called by status and operations before building URLs. +export function cleanPath(pagePath, ext) { + const withSlash = pagePath.startsWith('/') ? pagePath : `/${pagePath}`; + return withSlash.replace(new RegExp(`\\.${ext}$`), ''); +} diff --git a/tools/apps/msm/core/icons.js b/tools/apps/msm/core/icons.js new file mode 100644 index 0000000..d11f182 --- /dev/null +++ b/tools/apps/msm/core/icons.js @@ -0,0 +1,17 @@ +/* eslint-disable import/no-unresolved */ +import { html } from 'da-lit'; + +// Shared MSM icon helper. Icons live in ./img and are referenced via . +// The href is resolved against this module's URL (not the consuming document), +// so it works identically from the app and the dialog — and survives the +// da.live `/app/{owner}/{repo}/` proxy, where root-relative paths would break. +const ICON_DIR = new URL('./img/', import.meta.url).href; + +// Returns an inline using the named icon. Setting `--iconPrimary` to +// `currentColor` lets icons that hard-code that token follow the inherited +// text color, the same as the currentColor-based icons. +// eslint-disable-next-line import/prefer-default-export +export const icon = (name, viewBox = '0 0 20 20', w = 16, h = 16) => html` + `; diff --git a/tools/apps/msm/core/img/S2_Icon_AlertDiamond_20_N.svg b/tools/apps/msm/core/img/S2_Icon_AlertDiamond_20_N.svg new file mode 100644 index 0000000..5f7b26d --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_AlertDiamond_20_N.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/tools/apps/msm/core/img/S2_Icon_AlertTriangle_20_N.svg b/tools/apps/msm/core/img/S2_Icon_AlertTriangle_20_N.svg new file mode 100644 index 0000000..be7e9d3 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_AlertTriangle_20_N.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/apps/msm/core/img/S2_Icon_CheckmarkCircle_20_N.svg b/tools/apps/msm/core/img/S2_Icon_CheckmarkCircle_20_N.svg new file mode 100644 index 0000000..338e716 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_CheckmarkCircle_20_N.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tools/apps/msm/core/img/S2_Icon_ChevronDown_20_N.svg b/tools/apps/msm/core/img/S2_Icon_ChevronDown_20_N.svg new file mode 100644 index 0000000..4dd0c22 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_ChevronDown_20_N.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tools/apps/msm/core/img/S2_Icon_ChevronLeft_20_N.svg b/tools/apps/msm/core/img/S2_Icon_ChevronLeft_20_N.svg new file mode 100644 index 0000000..c5ae257 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_ChevronLeft_20_N.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tools/apps/msm/core/img/S2_Icon_ChevronRight_20_N.svg b/tools/apps/msm/core/img/S2_Icon_ChevronRight_20_N.svg new file mode 100644 index 0000000..6c3fb72 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_ChevronRight_20_N.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/apps/msm/core/img/S2_Icon_ClockPending_20_N.svg b/tools/apps/msm/core/img/S2_Icon_ClockPending_20_N.svg new file mode 100644 index 0000000..91dd39a --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_ClockPending_20_N.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tools/apps/msm/core/img/S2_Icon_Delete_20_N.svg b/tools/apps/msm/core/img/S2_Icon_Delete_20_N.svg new file mode 100644 index 0000000..2eb9d24 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_Delete_20_N.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/tools/apps/msm/core/img/S2_Icon_ExperienceAdd_20_N.svg b/tools/apps/msm/core/img/S2_Icon_ExperienceAdd_20_N.svg new file mode 100644 index 0000000..62cc8d1 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_ExperienceAdd_20_N.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tools/apps/msm/core/img/S2_Icon_ExperiencePreview_20_N.svg b/tools/apps/msm/core/img/S2_Icon_ExperiencePreview_20_N.svg new file mode 100644 index 0000000..7579b64 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_ExperiencePreview_20_N.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tools/apps/msm/core/img/S2_Icon_File_20_N.svg b/tools/apps/msm/core/img/S2_Icon_File_20_N.svg new file mode 100644 index 0000000..99cbd31 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_File_20_N.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tools/apps/msm/core/img/S2_Icon_Folder_20_N.svg b/tools/apps/msm/core/img/S2_Icon_Folder_20_N.svg new file mode 100644 index 0000000..dbb5058 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_Folder_20_N.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tools/apps/msm/core/img/S2_Icon_GlobeGrid_20_N.svg b/tools/apps/msm/core/img/S2_Icon_GlobeGrid_20_N.svg new file mode 100644 index 0000000..bf5cada --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_GlobeGrid_20_N.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tools/apps/msm/core/img/S2_Icon_LinkApplied_20_N.svg b/tools/apps/msm/core/img/S2_Icon_LinkApplied_20_N.svg new file mode 100644 index 0000000..7b8efda --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_LinkApplied_20_N.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tools/apps/msm/core/img/S2_Icon_Publish_20_N.svg b/tools/apps/msm/core/img/S2_Icon_Publish_20_N.svg new file mode 100644 index 0000000..efd467e --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_Publish_20_N.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/apps/msm/core/img/S2_Icon_UnLink_20_N.svg b/tools/apps/msm/core/img/S2_Icon_UnLink_20_N.svg new file mode 100644 index 0000000..13d75b7 --- /dev/null +++ b/tools/apps/msm/core/img/S2_Icon_UnLink_20_N.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/tools/apps/msm/core/operations.js b/tools/apps/msm/core/operations.js new file mode 100644 index 0000000..c319b55 --- /dev/null +++ b/tools/apps/msm/core/operations.js @@ -0,0 +1,94 @@ +/* eslint-disable import/no-unresolved */ +import { + daFetch, DA_ORIGIN, AEM_ADMIN, cleanPath, +} from './fetch.js'; + +const NX = 'https://da.live/nx'; + +const EXT_MIME_TYPES = { + html: 'text/html', + json: 'application/json', + svg: 'image/svg+xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + pdf: 'application/pdf', +}; + +// `editUrlOrigin` builds the in-editor deep link returned to the UI. The dialog +// runs in a cross-origin iframe, so its init sets this to the real da.live host. +let editUrlOrigin = 'https://da.live'; +export function setEditUrlOrigin(origin) { if (origin) editUrlOrigin = origin; } +export function getEditUrlOrigin() { return editUrlOrigin; } + +let mergeCopyFn; +export function setMergeCopy(fn) { mergeCopyFn = fn; } +async function ensureMergeCopy() { + if (!mergeCopyFn) { + const mod = await import(`${NX}/blocks/loc/project/index.js`); + mergeCopyFn = mod.mergeCopy; + } + return mergeCopyFn; +} + +export async function previewPage(org, site, pagePath, ext = 'html') { + const clean = cleanPath(pagePath, ext); + const aemPath = ext === 'html' ? clean : `${clean}.${ext}`; + const resp = await daFetch(`${AEM_ADMIN}/preview/${org}/${site}/main${aemPath}`, { method: 'POST' }); + if (!resp.ok) return { error: resp.headers?.get('x-error') || `Preview failed (${resp.status})` }; + return resp.json(); +} + +export async function publishPage(org, site, pagePath, ext = 'html') { + const clean = cleanPath(pagePath, ext); + const aemPath = ext === 'html' ? clean : `${clean}.${ext}`; + const resp = await daFetch(`${AEM_ADMIN}/live/${org}/${site}/main${aemPath}`, { method: 'POST' }); + if (!resp.ok) return { error: resp.headers?.get('x-error') || `Publish failed (${resp.status})` }; + return resp.json(); +} + +// Detach a page on `targetSite` by writing it an independent copy of the +// source's content. Also the primitive behind a "replace" sync (overwrite the +// existing copy from source). +export async function copyFromSource(org, sourceSite, targetSite, pagePath, ext = 'html') { + const clean = cleanPath(pagePath, ext); + const sourceUrl = `${DA_ORIGIN}/source/${org}/${sourceSite}${clean}.${ext}`; + const resp = await daFetch(sourceUrl); + if (!resp.ok) return { error: `Failed to fetch source content (${resp.status})` }; + + const content = await resp.blob(); + const mimeType = EXT_MIME_TYPES[ext] || 'application/octet-stream'; + const formData = new FormData(); + formData.append('data', new Blob([content], { type: mimeType })); + + const targetUrl = `${DA_ORIGIN}/source/${org}/${targetSite}${clean}.${ext}`; + const saveResp = await daFetch(targetUrl, { method: 'PUT', body: formData }); + if (!saveResp.ok) return { error: `Failed to copy from source (${saveResp.status})` }; + return { ok: true }; +} + +// Reconnect a page by deleting its independent copy so it links to its source again. +export async function deleteCopy(org, site, pagePath, ext = 'html') { + const clean = cleanPath(pagePath, ext); + const resp = await daFetch(`${DA_ORIGIN}/source/${org}/${site}${clean}.${ext}`, { method: 'DELETE' }); + if (!resp.ok) return { error: `Failed to remove copy (${resp.status})` }; + return { ok: true }; +} + +export async function mergeFromSource(org, sourceSite, targetSite, pagePath, ext = 'html') { + try { + const clean = cleanPath(pagePath, ext); + const mergeCopy = await ensureMergeCopy(); + const url = { + source: `/${org}/${sourceSite}${clean}.${ext}`, + destination: `/${org}/${targetSite}${clean}.${ext}`, + }; + const result = await mergeCopy(url, 'MSM Merge'); + if (!result?.ok) return { error: 'Merge failed' }; + return { ok: true, editUrl: `${editUrlOrigin}/edit#/${org}/${targetSite}${clean}` }; + } catch (e) { + return { error: e.message || 'Merge failed' }; + } +} diff --git a/tools/apps/msm/core/source-tree.js b/tools/apps/msm/core/source-tree.js new file mode 100644 index 0000000..f7eeb49 --- /dev/null +++ b/tools/apps/msm/core/source-tree.js @@ -0,0 +1,49 @@ +// Shared source-resolution logic for the MSM matrix (app) and the per-page +// linked tree (plugin). Both arrange linked sites into a parent→child tree and +// must answer the same question for any target site: which site does it pull +// from? The answer is the nearest ancestor that holds a detached copy, else the +// root site. Keeping this in one place stops the two views from drifting — e.g. +// a confirm dialog naming one source while the operation copies from another. + +// Map of child site -> parent site; top-level nodes map to `rootSite`. `keyOf` +// reads a node's site id, since the app keys nodes on `site` and the plugin on +// `siteId`. +export function buildParentMap(tree, rootSite, keyOf) { + const m = new Map(); + const walk = (nodes, parent) => nodes.forEach((n) => { + m.set(keyOf(n), parent); + if (n.children?.length) walk(n.children, keyOf(n)); + }); + walk(tree, rootSite); + return m; +} + +// Ancestors of `site`, nearest parent first, stopping before the root sentinel +// (top-level nodes map to `rootSite`, which is not itself a tree node). +export function ancestorChain(site, pm, rootSite) { + const chain = []; + let cur = pm.get(site); + while (cur && cur !== rootSite) { chain.push(cur); cur = pm.get(cur); } + return chain; +} + +// The source a target site pulls from: the nearest ancestor with a detached +// copy (per `lookup`), else the root. `lookup(site)` returns +// `{ isDetached, lastModified }` (or undefined when that site isn't loaded yet). +// Returns `{ site, lm }`; for the root, `lm` is the caller-supplied `rootLm`. +export function effectiveSource(targetSite, pm, lookup, rootSite, rootLm) { + let cur = pm.get(targetSite); + while (cur && cur !== rootSite) { + const info = lookup(cur); + if (info?.isDetached) return { site: cur, lm: info.lastModified ?? null }; + cur = pm.get(cur); + } + return { site: rootSite, lm: rootLm ?? null }; +} + +// A detached copy is behind its source when the source changed after the copy's +// last-modified time, beyond the publish-lag grace window. +export function isOutOfSync(sourceLm, selfLm, lagMs) { + return !!(sourceLm && selfLm + && new Date(sourceLm).getTime() > new Date(selfLm).getTime() + lagMs); +} diff --git a/tools/apps/msm/core/status.js b/tools/apps/msm/core/status.js new file mode 100644 index 0000000..8e07ece --- /dev/null +++ b/tools/apps/msm/core/status.js @@ -0,0 +1,84 @@ +/* eslint-disable import/no-unresolved */ +import { + daFetch, DA_ORIGIN, AEM_ADMIN, PUBLISH_LAG_MS, cleanPath, +} from './fetch.js'; + +// HEAD a source file to learn whether it exists and its last-modified time. +export async function getPageTimestamp(org, site, pagePath, ext = 'html') { + const clean = cleanPath(pagePath, ext); + const resp = await daFetch(`${DA_ORIGIN}/source/${org}/${site}${clean}.${ext}`, { method: 'HEAD', cache: 'no-store' }); + return { exists: resp.ok, lastModified: resp.headers?.get('Last-Modified') || null }; +} + +// Rich publish status for a single page. `editLastModified` is the source +// timestamp used to decide whether the published preview/live copies are +// current or behind. +export async function getPageStatus(org, site, pagePath, editLastModified = null, ext = 'html') { + const clean = cleanPath(pagePath, ext); + const aemPath = ext === 'html' ? clean : `${clean}.${ext}`; + const resp = await daFetch(`${AEM_ADMIN}/status/${org}/${site}/main${aemPath}`, { cache: 'no-store' }); + if (!resp.ok) { + return { + previewState: 'not-published', liveState: 'not-published', previewDate: null, liveDate: null, + }; + } + const json = await resp.json(); + + const toTime = (v) => (v ? new Date(v).getTime() : null); + const editTime = toTime(editLastModified); + const previewTime = toTime(json.preview?.lastModified); + const liveTime = toTime(json.live?.lastModified); + + let previewState; + if (json.preview?.status !== 200) { + previewState = 'not-published'; + } else if (editTime !== null && previewTime !== null && editTime > previewTime + PUBLISH_LAG_MS) { + previewState = 'behind'; + } else { + previewState = 'current'; + } + + let liveState; + if (json.live?.status !== 200) { + liveState = 'not-published'; + } else if ( + (previewTime !== null && liveTime !== null && previewTime > liveTime) + || (editTime !== null && liveTime !== null && editTime > liveTime + PUBLISH_LAG_MS) + ) { + liveState = 'behind'; + } else { + liveState = 'current'; + } + + return { + previewState, + liveState, + previewDate: json.preview?.lastModified || null, + liveDate: json.live?.lastModified || null, + }; +} + +// Maps link + publish state to a status icon. Returns { name, color, tip }. +export function getStatusConfig({ + isDetached, outOfSync, previewState, liveState, +}) { + const green = (tip) => ({ name: 'S2_Icon_CheckmarkCircle_20_N', color: 'var(--s2-green-700,#0ba45d)', tip }); + const amber = (tip) => ({ name: 'S2_Icon_AlertTriangle_20_N', color: 'var(--s2-yellow-700,#e68619)', tip }); + const orange = (tip) => ({ name: 'S2_Icon_AlertTriangle_20_N', color: 'var(--s2-orange-600,#fc7d00)', tip }); + const red = (tip) => ({ name: 'S2_Icon_AlertDiamond_20_N', color: 'var(--s2-red-700,#ff513d)', tip }); + + if (!isDetached) { + if (liveState === 'current') return green('Live and current'); + if (previewState === 'current') return amber('Previewed — not yet published to live'); + if (liveState === 'not-published' && previewState === 'not-published') return red('Not published'); + return red('Source changed — publish needed'); + } + + if (outOfSync) { + if (liveState === 'current') return orange('Behind source — changed since last sync'); + return red('Behind source — needs sync and publish'); + } + if (liveState === 'current') return green('Live and current'); + if (previewState === 'current') return amber('Previewed — not yet published to live'); + return red('Not yet previewed or published'); +} diff --git a/tools/apps/msm/helpers/action-panel.css b/tools/apps/msm/helpers/action-panel.css new file mode 100644 index 0000000..35b21fc --- /dev/null +++ b/tools/apps/msm/helpers/action-panel.css @@ -0,0 +1,709 @@ +:host { + display: block; +} + +.panel-empty { + padding: 24px; + text-align: center; + color: var(--s2-gray-500); + font-size: 14px; + font-style: italic; +} + +.panel { + border: 1px solid var(--s2-gray-200); + border-radius: 8px; + overflow: hidden; +} + +/* ===== Header ===== */ + +.panel-header { + display: flex; + align-items: baseline; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--s2-gray-200); + background: var(--s2-gray-75, #f8f8f8); +} + +.panel-sub { + font-size: 13px; + color: var(--s2-gray-600); +} + +/* ===== Tabs (dual / mid-tier sites) ===== */ + +.tabs { + display: flex; + gap: 4px; + padding: 8px 16px 0; + border-bottom: 1px solid var(--s2-gray-200); +} + +.tab { + appearance: none; + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 8px 12px; + font-size: 13px; + font-weight: 600; + color: var(--s2-gray-600); + cursor: pointer; +} + +.tab.active { + color: var(--s2-gray-900); + border-bottom-color: var(--s2-blue-800, #1473e6); +} + +.tab:hover:not(.active) { + color: var(--s2-gray-800); +} + +/* ===== Section ===== */ + +.section-head { + padding: 10px 16px 6px; +} + +.section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); +} + +/* Inheritance breadcrumb — the panel's primary header */ +.chain { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 16px; +} + +.chain-node { + color: var(--s2-gray-600); +} + +.chain-node.current { + color: var(--s2-gray-900); + font-weight: 700; +} + +.chain-link { + appearance: none; + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--s2-blue-800, #1473e6); + cursor: pointer; +} + +.chain-link:disabled { + color: var(--s2-gray-400); + cursor: default; +} + +.chain-link:hover:not(:disabled) { + text-decoration: underline; +} + +.chain-sep { + color: var(--s2-gray-400); +} + +/* ===== Matrix ===== */ + +.matrix-scroll { + overflow-x: auto; + padding: 0 16px; +} + +.matrix { + border-collapse: collapse; + font-size: 13px; + width: 100%; +} + +.matrix th, +.matrix td { + border-bottom: 1px solid var(--s2-gray-150, #e8e8e8); + padding: 0; +} + +.matrix thead th { + border-bottom: 1px solid var(--s2-gray-300); + vertical-align: bottom; +} + +.matrix .corner { + text-align: left; + width: 1%; + white-space: nowrap; + padding: 8px 16px 8px 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); +} + +/* Target column headers */ +.matrix .target { + padding: 8px 10px; + text-align: center; + min-width: 84px; +} + +.matrix .target.off { + opacity: 0.45; +} + +/* Nested-level columns get a grouping rule on their left edge */ +.matrix .target.nested, +.matrix .cell.nested { + border-left: 1px solid var(--s2-gray-200); +} + +.target-head { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +.target-label { + max-width: 96px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; + color: var(--s2-gray-800); +} + +/* Linked-site name: reads as a column label, reveals its jump affordance + (down the tree) on hover so a row of columns isn't a wall of blue links. */ +.target-jump { + appearance: none; + background: none; + border: none; + padding: 0; + font: inherit; + font-weight: 600; + color: var(--s2-gray-800); + cursor: pointer; +} + +.target-jump:disabled { + color: var(--s2-gray-400); + cursor: default; +} + +.target-jump:hover:not(:disabled) { + color: var(--s2-blue-800, #1473e6); + text-decoration: underline; +} + +.col-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px 3px; + border: none; + border-radius: 4px; + background: var(--s2-gray-100); + color: var(--s2-gray-600); + cursor: pointer; + font-size: 11px; + font-weight: 600; +} + +.col-toggle:hover:not(:disabled) { + background: var(--s2-gray-200); +} + +.col-toggle svg { + transition: transform 0.15s; +} + +.col-toggle.open svg { + transform: rotate(90deg); +} + +.col-count { + line-height: 1; +} + +/* Page row headers */ +.matrix .page { + text-align: left; + padding: 6px 16px 6px 4px; + font-weight: 400; + min-width: 240px; + max-width: 320px; +} + +.page-cell { + display: flex; + align-items: center; + gap: 6px; +} + +.page-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--s2-gray-900); +} + +/* Per-row remove — subtle, revealed on row hover / focus */ +.row-remove { + appearance: none; + background: none; + border: none; + flex-shrink: 0; + padding: 0 2px; + font-size: 16px; + line-height: 1; + color: var(--s2-gray-500); + cursor: pointer; + opacity: 0; + transition: opacity 0.1s; +} + +.row-remove:focus-visible { + opacity: 1; + outline: 2px solid var(--s2-blue-800, #1473e6); + outline-offset: 1px; +} + +.row-remove:hover:not(:disabled) { + color: var(--s2-gray-900); +} + +.matrix tr:hover .row-remove, +.matrix tr:focus-within .row-remove { + opacity: 1; +} + +a.page-link { + color: var(--s2-blue-800, #1473e6); + text-decoration: none; +} + +a.page-link:hover { + text-decoration: underline; +} + +.matrix .target.off .target-label { + color: var(--s2-gray-400); +} + +/* Cells */ +.matrix .cell { + text-align: center; + height: 34px; +} + +.matrix .cell.dim { + opacity: 0.25; +} + +/* Cells / page rows the pending confirm will act on — tinted to match the + confirm row (blue, or red when the action is destructive). */ +.matrix .cell.affected { + background: var(--s2-blue-100, #e0ecff); + box-shadow: inset 0 0 0 2px var(--s2-blue-400, #66a3ff); +} + +.matrix .page.affected { + background: var(--s2-blue-100, #e0ecff); +} + +.matrix .cell.affected.destructive { + background: var(--s2-red-100, #ffefed); + box-shadow: inset 0 0 0 2px var(--s2-red-400, #ffaaa0); +} + +.matrix .page.affected.destructive { + background: var(--s2-red-100, #ffefed); +} + +/* Rows with nothing in scope for the pending confirm are de-emphasized (not + hidden) so the affected rows stand out while the full selection stays in view. + Distinct from `.dim`, which marks excluded target columns. */ +.matrix tr.row-deemph { + opacity: 0.4; +} + +/* Un-applied cells inside an affected row — dimmed so the highlighted cells in + the same row stand out. Only applied where the row is not already deemphasized + (see `_cellHl`), so opacities never compound. */ +.matrix .cell.cell-deemph { + opacity: 0.35; +} + +.cell-pair { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +/* A local-copy cell links to that site's editor doc */ +.cell-link { + display: inline-flex; + text-decoration: none; + padding: 2px 4px; + border-radius: 4px; +} + +.cell-link:hover { + background: var(--s2-gray-100, #f5f5f5); +} + +/* ===== Upward (source) table ===== */ + +.up-col { + padding: 8px 12px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); +} + +.up-table .cell { + text-align: left; + padding-left: 12px; +} + +.cell-inherit { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--s2-gray-500); +} + +.cell-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; +} + +.cell-loading { + width: 12px; + height: 12px; + border: 2px solid var(--s2-gray-200); + border-top-color: var(--s2-gray-500); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Checkbox label wrapper (header + rows) */ +.cb { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.cb input[type="checkbox"] { + appearance: none; + width: 14px; + height: 14px; + margin: 0; + border: 2px solid var(--s2-gray-600); + border-radius: 2px; + cursor: pointer; + flex-shrink: 0; + position: relative; + background: transparent; + transition: background-color 0.13s, border-color 0.13s; +} + +.cb input[type="checkbox"]:checked, +.cb input[type="checkbox"]:indeterminate { + background: var(--s2-gray-800); + border-color: var(--s2-gray-800); +} + +.cb input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + margin-top: -1px; + width: 4px; + height: 8px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.cb input[type="checkbox"]:indeterminate::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + width: 7px; + height: 2px; + background: #fff; + border-radius: 1px; +} + + +/* ===== Action bar ===== */ + +.action-bar { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px; + border-top: 1px solid var(--s2-gray-200); + background: var(--s2-gray-75, #f8f8f8); +} + +/* A row of actions grouped by the cell state they target, with a leading label. + The fixed-width label aligns the button columns across the two rows. */ +.action-row { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.action-row-label { + flex-shrink: 0; + min-width: 76px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); +} + +.action-group { + display: flex; + align-items: center; + gap: 8px; +} + +/* Shown above the action bar while cells are still resolving — explains why the + buttons are disabled. */ +.action-calc { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 16px 0; + padding: 8px 14px; + border: 1px solid var(--s2-gray-200); + background: var(--s2-gray-75, #f8f8f8); + border-radius: 8px; + font-size: 13px; + color: var(--s2-gray-600); +} + +.action-label { + font-size: 12px; + font-weight: 600; + color: var(--s2-gray-600); +} + +/* ===== Confirm row ===== */ + +.confirm-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: 12px 16px 0; + padding: 10px 14px; + border: 1px solid var(--s2-blue-300, #b3d4ff); + background: var(--s2-blue-100, #e0ecff); + border-radius: 8px; +} + +.confirm-row.destructive { + border-color: var(--s2-red-400, #ffaaa0); + background: var(--s2-red-100, #ffefed); +} + +.confirm-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.confirm-msg { + font-size: 13px; + color: var(--s2-gray-900); +} + +.confirm-note { + font-size: 12px; + color: var(--s2-gray-600); +} + +.confirm-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.s2-btn { + appearance: none; + border-radius: 16px; + border: 1px solid transparent; + padding: 5px 14px; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.s2-btn-confirm { + background: var(--s2-gray-800); + color: #fff; +} + +.s2-btn-confirm:disabled { + background: var(--s2-gray-300); + cursor: not-allowed; +} + +.s2-btn-outline { + background: transparent; + border-color: var(--s2-gray-400); + color: var(--s2-gray-800); +} + +/* ===== Success banner ===== */ + +.success-banner { + display: flex; + flex-direction: column; + gap: 10px; + margin: 12px 16px 0; + padding: 10px 14px; + border: 1px solid var(--s2-green-400, #a3e3bf); + background: var(--s2-green-100, #e8f8ef); + border-radius: 8px; +} + +.success-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.success-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: var(--s2-gray-900); +} + +.success-links { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; +} + +.success-link { + font-size: 12px; + color: var(--s2-blue-800, #1473e6); + text-decoration: none; +} + +.success-link:hover { + text-decoration: underline; +} + +.success-more { + font-size: 12px; + color: var(--s2-gray-600); +} + +.success-banner.has-errors { + border-color: var(--s2-orange-400, #ffbe7d); + background: var(--s2-orange-100, #fff3e6); +} + +.success-banner.has-errors .success-title svg { + color: var(--s2-orange-700, #e68619); +} + +.error-list { + margin: 0; + padding-left: 18px; + font-size: 12px; + color: var(--s2-gray-800); +} + +.error-list li { + padding: 1px 0; +} + +/* ===== Busy banner ===== */ + +.busy-banner { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 16px 0; + padding: 10px 14px; + border: 1px solid var(--s2-gray-300); + background: var(--s2-gray-75, #f8f8f8); + border-radius: 8px; + font-size: 13px; + color: var(--s2-gray-700); +} + +.busy-spinner { + width: 14px; + height: 14px; + border: 2px solid var(--s2-gray-200); + border-top-color: var(--s2-gray-600); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.success-title svg { + color: var(--s2-green-700, #0ba45d); +} + +.success-dismiss { + appearance: none; + background: transparent; + border: none; + color: var(--s2-gray-700); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +/* ===== Responsive ===== */ + +@media (width <= 600px) { + .action-bar { + gap: 12px; + } +} diff --git a/tools/apps/msm/helpers/action-panel.js b/tools/apps/msm/helpers/action-panel.js new file mode 100644 index 0000000..cb29742 --- /dev/null +++ b/tools/apps/msm/helpers/action-panel.js @@ -0,0 +1,1062 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ +import { LitElement, html, nothing } from 'da-lit'; +import { + getSiteRoles, + getPageTimestamp, + getPageStatus, + getStatusConfig, + executeBulkAction, + PUBLISH_LAG_MS, +} from './api.js'; +import { icon } from '../core/icons.js'; +import { + cellKey, + findNode, + subtreeSites, + parentMap, + flattenAll, + flattenVisible, + columnState, + toggleTarget, + effectiveSource, + deriveCategory, + isOutOfSync, + scopedCells, + scopedPagesUp, + downGroups, + upGroups, + ancestorsToExpand, + matrixComplete, + sourceComplete, + planSelectionLoad, +} from './action-panel.model.js'; + +const NX = 'https://da.live/nx'; +let sl; +let sheet; +try { + const { default: getStyle } = await import(`${NX}/utils/styles.js`); + [sl, sheet] = await Promise.all([ + getStyle(`${NX}/public/sl/styles.css`), + getStyle(import.meta.url), + ]); +} catch (e) { + console.warn('Failed to load action-panel styles:', e); +} + +// How many status probes to run at once when filling the matrix / table. +const CELL_CONCURRENCY = 6; + +const sanitizeId = (s) => s.replace(/[^a-zA-Z0-9]/g, '-'); + +/* eslint-disable no-restricted-syntax, no-await-in-loop */ +async function runPool(makeTasks, limit) { + const executing = new Set(); + for (const task of makeTasks) { + const p = task().finally(() => executing.delete(p)); + executing.add(p); + if (executing.size >= limit) await Promise.race(executing); + } + await Promise.all(executing); +} +/* eslint-enable no-restricted-syntax, no-await-in-loop */ + +class MsmActionPanel extends LitElement { + static properties = { + org: { type: String }, + site: { type: String }, + msmConfig: { attribute: false }, + pages: { attribute: false }, + _cells: { state: true }, + _rows: { state: true }, + _includedTargets: { state: true }, + _expandedCols: { state: true }, + _tab: { state: true }, + _confirm: { state: true }, + _busy: { state: true }, + _taskStatus: { state: true }, + _success: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [sl, sheet].filter(Boolean); + this._cells = new Map(); + this._rows = new Map(); + this._includedTargets = new Set(); + this._expandedCols = new Set(); + this._tab = 'linked'; + this._confirm = null; + this._busy = false; + this._taskStatus = new Map(); + this._success = null; + this._loadGen = 0; + this._contextKey = ''; + this._selKey = ''; + // Pages we've already dispatched loads for, per view — the basis for + // loading only newly-added pages on a selection change. + this._loadedDownPaths = new Set(); + this._loadedUpPaths = new Set(); + this._busyTotal = 0; + // Expansion snapshot taken when a confirm auto-reveals affected columns, so + // it can be restored when the confirm is dismissed or completes. + this._preConfirmExpanded = null; + } + + updated(changed) { + if (changed.has('pages') || changed.has('site') || changed.has('msmConfig')) { + this._resetForSelection(); + } + } + + // ── Derived shape ───────────────────────────────────────────────────────── + + get _roles() { + if (!this.msmConfig || !this.site) return {}; + return getSiteRoles(this.msmConfig, this.site); + } + + // Selected pages confined to the active site (selection is single-site, but + // guard anyway so a stale multi-site set never renders against this site). + get _pages() { + return (this.pages || []).filter((p) => (p.site || this.site) === this.site); + } + + // Direct linked sites of this site as [linkedSite, { label, descendantCount, descendants }]. + get _targets() { + return Object.entries(this._roles.asSource?.linked || {}); + } + + // The full linked-site subtree as column nodes: { site, label, children }. + get _columnTree() { + return this._targets.map(([site, info]) => ({ + site, label: info.label || site, children: info.descendants || [], + })); + } + + // Flattened, depth-first list of *visible* columns (children shown only when + // the parent is expanded). + get _columns() { + return flattenVisible(this._columnTree, this.site, this._expandedCols); + } + + // Every column in the subtree regardless of expansion — the basis for data + // loading, target inclusion, and action scope (collapsed levels still count). + get _allColumns() { + return flattenAll(this._columnTree, this.site); + } + + _subtreeSites(site) { + return subtreeSites(this._columnTree, site); + } + + _columnState(site) { + return columnState(this._columnTree, site, this._includedTargets); + } + + _parentMap() { + return parentMap(this._columnTree, this.site); + } + + _findNode(site) { + return findNode(this._columnTree, site); + } + + _labelFor(targetSite) { + return this._findNode(targetSite)?.label || targetSite; + } + + _targetLabel(view, target) { + return view === 'linked' ? this._labelFor(target) : this._siteTitle(target); + } + + // Human title for a site id, from the MSM config rows (source or linked-site + // row's title). Falls back to the id when no title is configured. + _siteTitle(site) { + const rows = this.msmConfig?.rows || []; + const sourceRow = rows.find((r) => (r.base ?? r.source) === site && !(r.satellite ?? r.linked)); + if (sourceRow?.title) return sourceRow.title; + const linkedRow = rows.find((r) => (r.satellite ?? r.linked) === site); + return linkedRow?.title || site; + } + + // The source a linked site pulls from for a given page: the nearest ancestor + // that holds a detached copy of the page, else the root source. Resolves from + // already-loaded cells (ancestors load before descendants). + _effectiveSource(page, targetSite, pm) { + return effectiveSource(page, targetSite, pm, this._cells, this.site); + } + + // ── Selection lifecycle ───────────────────────────────────────────────── + + // A context change (different org/site — always a full reset, since selection + // is single-site) wipes and reloads everything. A selection change within the + // same context keeps loaded rows and fetches only the newly-added pages; + // removed pages just stop rendering. Column include/expand state is preserved + // across selection changes — it's about linked sites, not pages. + _resetForSelection() { + const contextKey = `${this.org}|${this.site}`; + const selKey = this._pages.map((p) => p.path).sort().join(','); + const plan = planSelectionLoad({ + prevContextKey: this._contextKey, + prevSelKey: this._selKey, + contextKey, + selKey, + pages: this._pages, + loadedDownPaths: this._loadedDownPaths, + loadedUpPaths: this._loadedUpPaths, + hasSource: !!this._roles.asSource, + hasLinked: !!this._roles.asLinked, + }); + if (plan.kind === 'noop') return; + this._contextKey = contextKey; + this._selKey = selKey; + + if (plan.kind === 'reset') { + this._loadGen += 1; + this._cells = new Map(); + this._rows = new Map(); + this._loadedDownPaths = new Set(); + this._loadedUpPaths = new Set(); + this._includedTargets = new Set(this._allColumns.map((c) => c.site)); + this._expandedCols = new Set(); + this._preConfirmExpanded = null; + this._tab = 'linked'; + this._taskStatus = new Map(); + this._confirm = null; + this._success = null; + } else { + // Selection changed within the context: a pending confirm's scope is stale. + this._confirm = null; + this._restoreExpanded(); + } + + if (plan.downPages.length) this._loadAll(plan.downPages); + if (plan.upPages.length) this._loadRows(plan.upPages); + } + + // ── Downward matrix data ────────────────────────────────────────────────── + + _setCell(key, data) { + const next = new Map(this._cells); + next.set(key, data); + this._cells = next; + } + + async _loadCellsFor(sites, pages = this._pages) { + const gen = this._loadGen; + const pm = this._parentMap(); + const tasks = []; + pages.forEach((page) => { + const ext = page.ext || 'html'; + sites.forEach((targetSite) => { + tasks.push(async () => { + if (gen !== this._loadGen) return; + try { + const ts = await getPageTimestamp(this.org, targetSite, page.path, ext); + const isDetached = ts.exists; + const src = this._effectiveSource(page, targetSite, pm); + const editLM = isDetached ? ts.lastModified : src.lm; + const status = await getPageStatus(this.org, targetSite, page.path, editLM, ext); + const outOfSync = isDetached && isOutOfSync(src.lm, ts.lastModified, PUBLISH_LAG_MS); + if (gen !== this._loadGen) return; + this._setCell(cellKey(page.path, targetSite), { + isDetached, + outOfSync, + previewState: status.previewState, + liveState: status.liveState, + lastModified: ts.lastModified, + sourceSite: src.site, + }); + } catch { + if (gen !== this._loadGen) return; + this._setCell(cellKey(page.path, targetSite), { + isDetached: false, + outOfSync: false, + previewState: 'not-published', + liveState: 'not-published', + lastModified: null, + sourceSite: this.site, + }); + } + }); + }); + }); + await runPool(tasks, CELL_CONCURRENCY); + } + + // Probe the given pages across the entire subtree, shallow→deep so each + // level's source resolves against freshly-loaded ancestors. Defaults to the + // whole selection; callers pass a subset to load only added or acted pages. + async _loadAll(pages = this._pages) { + pages.forEach((p) => this._loadedDownPaths.add(p.path)); + const byDepth = new Map(); + this._allColumns.forEach((c) => { + if (!byDepth.has(c.depth)) byDepth.set(c.depth, []); + byDepth.get(c.depth).push(c.site); + }); + const depths = [...byDepth.keys()].sort((a, b) => a - b); + /* eslint-disable no-restricted-syntax, no-await-in-loop */ + for (const d of depths) await this._loadCellsFor(byDepth.get(d), pages); + /* eslint-enable no-restricted-syntax, no-await-in-loop */ + } + + // ── Upward (source) row data ────────────────────────────────────────────── + + _setRow(path, data) { + const next = new Map(this._rows); + next.set(path, data); + this._rows = next; + } + + // Nearest ancestor (incl. root source) that actually holds the page — the site + // to pull from / copy from. Probes the source chain in parallel. + async _resolveUpwardSource(page) { + const chain = this._roles.asLinked?.chain || []; + const nearestFirst = [...chain].reverse(); + const ext = page.ext || 'html'; + const probes = await Promise.all( + nearestFirst.map((node) => getPageTimestamp(this.org, node.site, page.path, ext) + .then((ts) => ({ node, ts })) + .catch(() => ({ node, ts: { exists: false } }))), + ); + const hit = probes.find((r) => r.ts.exists); + if (hit) return { site: hit.node.site, lm: hit.ts.lastModified, exists: true }; + return { site: this._roles.asLinked?.source, lm: null, exists: false }; + } + + async _loadRows(pages = this._pages) { + pages.forEach((p) => this._loadedUpPaths.add(p.path)); + const gen = this._loadGen; + const tasks = pages.map((page) => async () => { + if (gen !== this._loadGen) return; + const ext = page.ext || 'html'; + try { + const [selfTs, src] = await Promise.all([ + getPageTimestamp(this.org, this.site, page.path, ext), + this._resolveUpwardSource(page), + ]); + const isDetached = selfTs.exists; + const category = deriveCategory({ isDetached, sourceExists: src.exists }); + const editLM = isDetached ? selfTs.lastModified : src.lm; + const status = await getPageStatus(this.org, this.site, page.path, editLM, ext); + const outOfSync = category === 'detached' + && isOutOfSync(src.lm, selfTs.lastModified, PUBLISH_LAG_MS); + if (gen !== this._loadGen) return; + this._setRow(page.path, { + category, + isDetached, + outOfSync, + previewState: status.previewState, + liveState: status.liveState, + source: src.site, + }); + } catch { + if (gen !== this._loadGen) return; + this._setRow(page.path, { + category: 'linked', + isDetached: false, + outOfSync: false, + previewState: 'not-published', + liveState: 'not-published', + source: this._roles.asLinked?.source, + }); + } + }); + await runPool(tasks, CELL_CONCURRENCY); + } + + // ── Include / expand toggles ────────────────────────────────────────────── + + // Cascades like the dialog's scope chips: unchecking a column removes it and + // its whole subtree; checking it adds the subtree and re-enables ancestors. + _toggleTarget(site) { + this._includedTargets = toggleTarget(this._columnTree, this._includedTargets, site, this.site); + // Changing what's in scope invalidates a pending confirm (its count, named + // sites and auto-revealed columns were computed for the old scope). Dismiss + // it — same as a page-selection or tab change — so the user re-confirms. + if (this._confirm) this._dismissConfirm(); + } + + // Expansion is display-only — all subtree data is already loaded. + _toggleColumnExpand(targetSite) { + const next = new Set(this._expandedCols); + if (next.has(targetSite)) next.delete(targetSite); else next.add(targetSite); + this._expandedCols = next; + } + + // ── Scope ───────────────────────────────────────────────────────────────── + + // Included (page, linked-site) downward cells matching a scope. + // scope: 'linked' (publish / detach) | 'detached' (sync / reconnect) + _scopedCells(scope) { + return scopedCells(this._allColumns, this._pages, this._includedTargets, this._cells, scope); + } + + // Source-view (upward) pages matching a scope, by link category. + // scope: 'linked' (publish / detach) | 'detached' (sync / reconnect) + _scopedPagesUp(scope) { + return scopedPagesUp(this._pages, this._rows, scope); + } + + _countFor(view, scope) { + if (view === 'linked') return this._scopedCells(scope).length; + return this._scopedPagesUp(scope).length; + } + + // ── Execution ───────────────────────────────────────────────────────────── + + // Confirm detail: counts plus the distinct source and target site names, taken + // from the execution grouping. Because the grouping resolves each page's real + // source, multi-level cases that pull from different sources surface as + // multiple sourceNames (so the confirm won't claim a single wrong "from"). + _confirmDetail(view, scope) { + const groups = view === 'linked' ? this._downGroups(scope) : this._upGroups(scope); + const count = groups.reduce((n, g) => n + g.pages.length, 0); + const pageCount = new Set(groups.flatMap((g) => g.pages.map((pg) => pg.path))).size; + const sources = [...new Set(groups.map((g) => g.source))]; + const targets = [...new Set(groups.map((g) => g.target))]; + return { + count, + pageCount, + sourceNames: sources.map((s) => this._siteTitle(s)), + targetNames: targets.map((t) => this._siteTitle(t)), + }; + } + + _requestAction(def) { + this._confirm = { ...def, ...this._confirmDetail(def.view, def.scope) }; + this._success = null; + if (def.view === 'linked') this._revealAffected(def.scope); + } + + // Expand the ancestor columns needed to show every affected cell, snapshotting + // the prior expansion so `_restoreExpanded` can put it back. No-op (and no + // snapshot) when nothing collapsed is in scope. + _revealAffected(scope) { + const sites = new Set(this._scopedCells(scope).map((c) => c.targetSite)); + const needed = ancestorsToExpand(this._columnTree, this.site, sites); + const missing = [...needed].filter((s) => !this._expandedCols.has(s)); + if (!missing.length) return; + this._preConfirmExpanded = new Set(this._expandedCols); + this._expandedCols = new Set([...this._expandedCols, ...missing]); + } + + _restoreExpanded() { + if (!this._preConfirmExpanded) return; + this._expandedCols = this._preConfirmExpanded; + this._preConfirmExpanded = null; + } + + _dismissConfirm() { + this._confirm = null; + this._restoreExpanded(); + } + + // Group in-scope work into valid executeBulkAction calls — one target per + // call, the pages that share its source — so sync / detach pull from the + // right source at any tree depth (source can differ per page). + _downGroups(scope) { + return downGroups({ + tree: this._columnTree, + pages: this._pages, + allColumns: this._allColumns, + included: this._includedTargets, + cells: this._cells, + rootSite: this.site, + scope, + }); + } + + // Source view: target is always this site; source is each page's resolved + // nearest ancestor with content (already computed in `_rows`). + _upGroups(scope) { + return upGroups({ + pages: this._pages, + rows: this._rows, + scope, + base: this._roles.asLinked?.source, + target: this.site, + }); + } + + async _execute() { + if (this._busy || !this._confirm) return; + const { + view, exec, scope, syncMode, label, + } = this._confirm; + this._confirm = null; + this._busy = true; + this._taskStatus = new Map(); + + const onPageStatus = (key, status, error) => { + const next = new Map(this._taskStatus); + next.set(key, { status, error }); + this._taskStatus = next; + }; + + const groups = view === 'linked' ? this._downGroups(scope) : this._upGroups(scope); + this._busyTotal = groups.reduce((n, g) => n + g.pages.length, 0); + const succeeded = []; + const errors = []; + /* eslint-disable no-restricted-syntax, no-await-in-loop */ + for (const g of groups) { + const results = await executeBulkAction({ + org: this.org, + sourceSite: g.source, + pages: g.pages, + targets: { [g.target]: { label: this._targetLabel(view, g.target) } }, + action: exec, + syncMode, + onPageStatus, + }); + results.forEach((r) => { + if (r.status === 'fulfilled' && r.value?.status === 'success') { + succeeded.push(r.value.key); + } else { + const v = r.status === 'fulfilled' ? r.value : null; + errors.push({ key: v?.key || null, error: v?.error || r.reason?.message || 'Failed' }); + } + }); + } + /* eslint-enable no-restricted-syntax, no-await-in-loop */ + + // Recompute only the pages we acted on. Reloading them across the whole + // column tree (depth-ordered) also captures descendants whose effective + // source shifted — e.g. a new detached copy becomes the source for its subtree. + const actedPaths = new Set(groups.flatMap((g) => g.pages.map((p) => p.path))); + const actedPages = this._pages.filter((p) => actedPaths.has(p.path)); + if (view === 'linked') await this._loadAll(actedPages); + else await this._loadRows(actedPages); + this._taskStatus = new Map(); + this._busy = false; + this._restoreExpanded(); + this._success = { + label, action: exec, ok: succeeded.length, failed: errors.length, results: succeeded, errors, + }; + // Content changed on disk — tell the browser to drop its stale folder/status + // caches and re-list, so its link badges and status icons stay truthful. + this.dispatchEvent(new CustomEvent('content-changed', { bubbles: true, composed: true })); + } + + // Re-select the same pages at another site (an ancestor via the breadcrumb, + // or a linked site via a column header) so the panel re-renders in that site's + // context. The column browser resolves which of the paths exist there. + _navigateTo(site, paths) { + if (!site || !paths.length) return; + this.dispatchEvent(new CustomEvent('navigate-pages', { + detail: { site, paths }, bubbles: true, composed: true, + })); + } + + // ── Shared status rendering ───────────────────────────────────────────── + + _taskOverlay(key) { + const task = this._taskStatus.get(key); + if (!task) return null; + if (task.status === 'pending' || task.status === 'queued') { + return html``; + } + if (task.status === 'success') { + return html` + ${icon('S2_Icon_CheckmarkCircle_20_N')}`; + } + if (task.status === 'error') { + return html` + ${icon('S2_Icon_AlertDiamond_20_N')}`; + } + return null; + } + + _statusIcon(data) { + const cfg = getStatusConfig(data); + return html`${icon(cfg.name)}`; + } + + // The two-icon cell shared by the matrix and the source table: link state + // (linked / detached) followed by publish status. + _linkStatusPair(linkInfo, statusData) { + return html` + + ${icon(linkInfo.name, '0 0 20 20', 13, 13)} + ${this._statusIcon(statusData)} + `; + } + + // ── Render: downward matrix ─────────────────────────────────────────────── + + renderCell(page, targetSite) { + const overlay = this._taskOverlay(cellKey(page.path, targetSite)); + if (overlay) return overlay; + const cell = this._cells.get(cellKey(page.path, targetSite)); + if (!cell) return html``; + const inh = cell.isDetached + ? { name: 'S2_Icon_UnLink_20_N', tip: 'Detached (independent copy)' } + : { name: 'S2_Icon_LinkApplied_20_N', tip: `Linked to ${this._siteTitle(cell.sourceSite)}` }; + const body = this._linkStatusPair(inh, cell); + // A detached copy is editable on that site — link the cell to its doc. + if (cell.isDetached && (page.ext || 'html') === 'html') { + return html`${body}`; + } + return body; + } + + renderTargetHeader(col) { + const state = this._columnState(col.site); + const expanded = this._expandedCols.has(col.site); + return html` + +
+ ${col.childCount ? html` + ` : nothing} + + +
+ `; + } + + // Cells the pending confirm will act on, so they can be highlighted. Returns + // { cells: Set(cellKey), rows: Set(path), destructive } or null when no + // linked-view confirm is open. + _affectedDown() { + const c = this._confirm; + if (!c || c.view !== 'linked') return null; + const scoped = this._scopedCells(c.scope); + return { + cells: new Set(scoped.map((x) => cellKey(x.page.path, x.targetSite))), + rows: new Set(scoped.map((x) => x.page.path)), + destructive: !!c.destructive, + }; + } + + // Confirm class for a matrix cell: highlight if in scope; de-emphasize if it's + // an un-applied cell inside an affected row (so the highlighted ones stand + // out). Cells in an unaffected row are left alone — the row's `row-deemph` + // already dims them, and stacking would compound the opacity. + _cellHl(hl, mod, path, site) { + if (!hl) return ''; + if (hl.cells.has(cellKey(path, site))) return mod; + if (hl.rows.has(path)) return 'cell-deemph'; + return ''; + } + + renderMatrix() { + const columns = this._columns; + const hl = this._affectedDown(); + const mod = hl?.destructive ? 'affected destructive' : 'affected'; + return html` +
+ + + + + ${columns.map((col) => this.renderTargetHeader(col))} + + + + ${this._pages.map((page) => html` + + + ${columns.map((col) => html` + `)} + `)} + +
Page
+ ${this.renderPageCell(page)} + + ${this.renderCell(page, col.site)} +
+
`; + } + + // ── Render: upward table ────────────────────────────────────────────────── + + // Link state of a source-view row as an icon + tooltip (no inline text — the + // source table now mirrors the matrix's two-icon status cell). + _upLinkInfo(row) { + if (row.category === 'linked') { + return { name: 'S2_Icon_LinkApplied_20_N', tip: `Linked to ${this._siteTitle(row.source)}` }; + } + if (row.category === 'detached') { + return { name: 'S2_Icon_UnLink_20_N', tip: 'Detached' }; + } + return { name: 'S2_Icon_UnLink_20_N', tip: 'Local only (no source)' }; + } + + renderUpRow(page, hl) { + const row = this._rows.get(page.path); + const rid = `up-row-${sanitizeId(page.path)}`; + const mod = hl?.destructive ? 'affected destructive' : 'affected'; + const affected = hl?.paths.has(page.path) ? mod : ''; + const deemph = hl && !hl.paths.has(page.path) ? 'row-deemph' : ''; + return html` + + + ${this.renderPageCell(page)} + + + ${row ? this._linkStatusPair(this._upLinkInfo(row), row) : html``} + + `; + } + + // Pages the pending confirm will act on (source view), or null. + _affectedUp() { + const c = this._confirm; + if (!c || c.view !== 'source') return null; + return { + paths: new Set(this._scopedPagesUp(c.scope).map((p) => p.path)), + destructive: !!c.destructive, + }; + } + + renderUpTable() { + const hl = this._affectedUp(); + return html` +
+ + + + + + + + + ${this._pages.map((page) => this.renderUpRow(page, hl))} + +
PageStatus
+
`; + } + + // ── Render: action bar + confirm + success ──────────────────────────────── + + renderConfirm() { + const c = this._confirm; + if (!c) return nothing; + const p = `${c.pageCount} page${c.pageCount === 1 ? '' : 's'}`; + // Name the source only when the whole batch shares one — multi-level work + // can resolve to several sources, where a single source would be wrong. + const src = c.sourceNames.length === 1 ? c.sourceNames[0] : null; + let tgt = null; + if (c.targetNames.length === 1) [tgt] = c.targetNames; + else if (c.targetNames.length > 1) tgt = `${c.targetNames.length} sites: ${c.targetNames.join(', ')}`; + const from = src ? ` from ${src}` : ''; + const inTgt = tgt ? ` in ${tgt}` : ''; + let msg; + if (c.exec === 'publish' || c.exec === 'preview') { + // Push the source's content down to the target (destination framing). + msg = `${c.label} ${p}${from}${tgt ? ` to ${tgt}` : ''}?`; + } else if (c.exec === 'reconnect') { + // Relink the target's pages back up to their source. + msg = `${c.label} ${p}${inTgt}${src ? ` to ${src}` : ''}?`; + } else { + // Merge / Replace / Detach act on the copy held in the target, from its source. + msg = `${c.label} ${p}${inTgt}${from}?`; + } + return html` +
+
+
${msg}
+ ${c.note ? html`
${c.note}
` : nothing} +
+
+ + +
+
`; + } + + // True while the active view's cells/rows are still resolving. Actions stay + // disabled until then so a click can't act on only the loaded subset. + _viewLoading(view) { + return view === 'linked' + ? !matrixComplete(this._pages, this._allColumns, this._cells) + : !sourceComplete(this._pages, this._rows); + } + + // One action button. `text` is the visible label; `def` carries the confirm + // label + scope; `cls` is the sl-button style tier (filled / gray outline / + // red outline). Tier is positional, not per-action — the destructive warning + // rides the confirm row, not the button colour. + _actionBtn(text, cls, disabled, def) { + return html` this._requestAction(def)}>${text}`; + } + + // Two rows scoped to the active view, grouped by the cell state each set of + // actions targets: Linked (publish / preview / detach) and Detached (merge / + // replace / reconnect). + renderActionBar(view) { + const publishable = this._countFor(view, 'linked'); + const detached = this._countFor(view, 'detached'); + const down = view === 'linked'; + const loading = this._viewLoading(view); + const off = this._busy || loading || (down && this._includedTargets.size === 0); + const linkedOff = off || publishable === 0; + const detachedOff = off || detached === 0; + + const publishDef = { + view, exec: 'publish', scope: 'linked', label: 'Publish', + }; + const previewDef = { + view, exec: 'preview', scope: 'linked', label: 'Preview', + }; + const detachDef = { + view, exec: 'detach', scope: 'linked', label: 'Detach', destructive: true, note: 'Creates an independent copy, breaking the link to the source.', + }; + const mergeDef = { + view, exec: 'sync', scope: 'detached', syncMode: 'merge', label: 'Merge', note: 'Merges source changes into the independent copy.', + }; + const replaceDef = { + view, exec: 'sync', scope: 'detached', syncMode: 'replace', label: 'Replace', destructive: true, note: 'Replaces the independent copy with the current source content.', + }; + const reconnectDef = { + view, exec: 'reconnect', scope: 'detached', label: 'Reconnect', destructive: true, note: 'Removes the independent copy and restores the link to the source.', + }; + + return html` + ${loading ? html`
Calculating status…
` : nothing} +
+
+ Linked +
+ ${this._actionBtn('Publish', '', linkedOff, publishDef)} + ${this._actionBtn('Preview', 'primary outline', linkedOff, previewDef)} + ${this._actionBtn('Detach', 'negative outline', linkedOff, detachDef)} +
+
+
+ Detached +
+ ${this._actionBtn('Merge', '', detachedOff, mergeDef)} + ${this._actionBtn('Replace', 'primary outline', detachedOff, replaceDef)} + ${this._actionBtn('Reconnect', 'negative outline', detachedOff, reconnectDef)} +
+
+
`; + } + + // Where to open a succeeded (page, site) result. Publish/preview open the + // published page (preview → aem.page, live → aem.live); sync/detach open the + // editor for the now-detached copy. Reconnect removed the copy, so no link. + _resultUrl(action, pagePath, site) { + const clean = pagePath.replace(/\.html$/, ''); + if (action === 'preview' || action === 'publish') { + const host = action === 'publish' ? 'aem.live' : 'aem.page'; + return `https://main--${site}--${this.org}.${host}${clean}`; + } + if (action === 'reconnect') return null; + return `https://da.live/edit#/${this.org}/${site}${clean}`; + } + + _editUrl(site, pagePath) { + return `https://da.live/edit#/${this.org}/${site}${pagePath.replace(/\.html$/, '')}`; + } + + // Editor URL for a page's actual content doc: this site when it has a detached + // copy (or is the source), else the ancestor it links to. Only docs (html) + // have an editor; assets return null. + _editUrlForPage(page) { + if ((page.ext || 'html') !== 'html') return null; + const row = this._rows.get(page.path); + const site = row && row.category === 'linked' ? row.source : this.site; + if (!site) return null; + return this._editUrl(site, page.path); + } + + renderPageName(page) { + const url = this._editUrlForPage(page); + if (!url) return html`${page.path}`; + return html`${page.path}`; + } + + // Drop a page from the selection (the column browser owns it). + _emitDeselect(page) { + this.dispatchEvent(new CustomEvent('deselect-page', { + detail: { site: this.site, path: page.path }, bubbles: true, composed: true, + })); + } + + renderRemove(page) { + return html``; + } + + // Flex wrapper kept inside the so the cell still lays out as a table cell. + renderPageCell(page) { + return html`
${this.renderPageName(page)}${this.renderRemove(page)}
`; + } + + // Short "{site} · {page}" label for a `${path}:${site}` result key. + _resultLabel(key) { + const ci = key.lastIndexOf(':'); + const leaf = key.slice(0, ci).split('/').pop().replace(/\.[^/.]+$/, ''); + return `${this._siteTitle(key.slice(ci + 1))} · ${leaf}`; + } + + renderBusy() { + const done = [...this._taskStatus.values()] + .filter((t) => t.status === 'success' || t.status === 'error').length; + const total = this._busyTotal || this._taskStatus.size; + const progress = total ? ` ${done}/${total}` : ''; + return html` +
+ + Working…${progress} +
`; + } + + renderSuccess() { + const s = this._success; + if (!s) return nothing; + const links = (s.results || []).map((key) => { + const ci = key.lastIndexOf(':'); + const url = this._resultUrl(s.action, key.slice(0, ci), key.slice(ci + 1)); + return url ? { url, label: this._resultLabel(key) } : null; + }).filter(Boolean); + const shown = links.slice(0, 10); + const extra = links.length - shown.length; + + const errs = (s.errors || []).slice(0, 8).map((e) => ( + e.key ? `${this._resultLabel(e.key)}: ${e.error}` : e.error + )); + const moreErr = (s.errors?.length || 0) - errs.length; + const allFailed = s.failed > 0 && s.ok === 0; + + return html` +
+
+ ${icon(allFailed ? 'S2_Icon_AlertDiamond_20_N' : 'S2_Icon_CheckmarkCircle_20_N')} + ${s.label} — ${s.ok} succeeded${s.failed ? `, ${s.failed} failed` : ''} + +
+ ${shown.length ? html` + ` : nothing} + ${errs.length ? html` +
    + ${errs.map((m) => html`
  • ${m}
  • `)} + ${moreErr > 0 ? html`
  • +${moreErr} more
  • ` : nothing} +
` : nothing} +
`; + } + + // ── Render: sections ──────────────────────────────────────────────────── + + renderDownSection(tabbed) { + return html` +
+ ${tabbed ? nothing : html`
`} + ${this._targets.length + ? html` + ${this.renderMatrix()} + ${this.renderConfirm()} + ${this.renderActionBar('linked')}` + : html`
No linked sites configured for this site.
`} +
`; + } + + // Breadcrumb of the source chain (root → current). Ancestor segments are + // clickable: each re-selects the same pages at that source so its matrix can + // act on them. The current site isn't a link. Local-only pages (no source + // counterpart) are left out of the hand-off. + renderChain() { + const ancestors = (this._roles.asLinked?.chain || []) + .map((c) => ({ site: c.site, label: this._siteTitle(c.site) })); + const current = { site: this.site, label: this._siteTitle(this.site), current: true }; + const nodes = [...ancestors, current]; + const paths = this._pages + .filter((p) => this._rows.get(p.path)?.category !== 'local') + .map((p) => p.path); + return html` + ${nodes.map((node, i) => html` + ${i > 0 ? html`` : nothing} + ${node.current + ? html`${node.label}` + : html``} + `)} + `; + } + + renderUpSection(tabbed) { + return html` +
+ ${tabbed ? nothing : html`
`} + ${this.renderUpTable()} + ${this.renderConfirm()} + ${this.renderActionBar('source')} +
`; + } + + // The view in effect: a dual site picks via tabs; otherwise it's forced. + get _activeView() { + const roles = this._roles; + if (roles.asSource && roles.asLinked) return this._tab; + if (roles.asSource) return 'linked'; + if (roles.asLinked) return 'source'; + return null; + } + + _setTab(tab) { + if (this._tab === tab) return; + this._tab = tab; + this._confirm = null; + this._restoreExpanded(); + } + + renderTabs() { + const tab = this._tab; + return html` +
+ + +
`; + } + + render() { + if (!this._pages.length) { + return html`
Select pages in the browser to act on them.
`; + } + const dual = !!(this._roles.asSource && this._roles.asLinked); + const view = this._activeView; + + return html` +
+
+ ${this.renderChain()} + ${this._pages.length} page${this._pages.length === 1 ? '' : 's'} selected +
+ ${this._busy ? this.renderBusy() : this.renderSuccess()} + ${dual ? this.renderTabs() : nothing} + ${view === 'linked' ? this.renderDownSection(dual) : nothing} + ${view === 'source' ? this.renderUpSection(dual) : nothing} + ${!view ? html`
No MSM relationships configured for this site.
` : nothing} +
`; + } +} + +customElements.define('msm-action-panel', MsmActionPanel); diff --git a/tools/apps/msm/helpers/action-panel.model.js b/tools/apps/msm/helpers/action-panel.model.js new file mode 100644 index 0000000..baa74dd --- /dev/null +++ b/tools/apps/msm/helpers/action-panel.model.js @@ -0,0 +1,235 @@ +// Pure decision logic behind the MSM action panel. No DOM, no fetch, no Lit — +// everything here takes plain inputs (the linked-site column tree, the loaded +// cells/rows maps, the user's selection) and returns plain values, so it can be +// unit-tested directly. `action-panel.js` orchestrates IO and delegates these +// decisions here. +// +// Shared shapes: +// tree [{ site, label, children: [...] }] linked-site subtree as columns +// rootSite the site the panel is centered on (matrix source site) +// cells Map(cellKey(path, site) -> { isDetached, lastModified, ... }) +// rows Map(path -> { category, source, ... }) upward source-view rows +// included Set(site) target sites in scope + +import { + buildParentMap, + effectiveSource as resolveSource, + isOutOfSync as resolveOutOfSync, +} from '../core/source-tree.js'; + +// Stable key for a (page, linked-site) matrix cell. +export const cellKey = (pagePath, targetSite) => `${pagePath}:${targetSite}`; + +// First node in the tree matching `site`, or null. +export function findNode(tree, site) { + const find = (nodes) => { + let result = null; + nodes.some((n) => { + if (n.site === site) { result = n; return true; } + result = find(n.children || []); + return result !== null; + }); + return result; + }; + return find(tree); +} + +// `site` plus every descendant under it. Falls back to `[site]` when the site +// isn't in the tree (e.g. the root itself). +export function subtreeSites(tree, site) { + const node = findNode(tree, site); + if (!node) return [site]; + const out = []; + const collect = (n) => { out.push(n.site); (n.children || []).forEach(collect); }; + collect(node); + return out; +} + +// Map of child site -> parent site; top-level columns map to `rootSite`. +export const parentMap = (tree, rootSite) => buildParentMap(tree, rootSite, (n) => n.site); + +// Every column in the subtree, depth-first, regardless of expansion — the basis +// for data loading, target inclusion, and action scope. +export function flattenAll(tree, rootSite) { + const out = []; + const walk = (nodes, depth, parentSite) => nodes.forEach((n) => { + out.push({ + site: n.site, label: n.label, depth, parentSite, childCount: n.children?.length || 0, + }); + if (n.children?.length) walk(n.children, depth + 1, n.site); + }); + walk(tree, 0, rootSite); + return out; +} + +// Visible columns only: children appear when their parent is in `expanded`. +export function flattenVisible(tree, rootSite, expanded) { + const out = []; + const walk = (nodes, depth, parentSite) => { + nodes.forEach((n) => { + const childCount = n.children?.length || 0; + out.push({ + site: n.site, label: n.label, depth, parentSite, childCount, + }); + if (childCount && expanded.has(n.site)) walk(n.children, depth + 1, n.site); + }); + }; + walk(tree, 0, rootSite); + return out; +} + +// Tri-state of a column's checkbox given the included set, spanning its subtree. +export function columnState(tree, site, included) { + const sub = subtreeSites(tree, site); + const inc = sub.filter((s) => included.has(s)).length; + if (inc === 0) return 'unchecked'; + if (inc === sub.length) return 'checked'; + return 'indeterminate'; +} + +// Cascade like the dialog's scope chips: a fully-checked column unchecks its +// whole subtree; a partially-checked (indeterminate) or unchecked column checks +// the subtree and re-enables ancestors. Keying off full-checkedness — not mere +// parent membership — is what makes an indeterminate parent fill in (check all) +// rather than wipe out. Returns a new Set (does not mutate `included`). +export function toggleTarget(tree, included, site, rootSite) { + const next = new Set(included); + const sub = subtreeSites(tree, site); + const fullyChecked = sub.every((s) => next.has(s)); + if (fullyChecked) { + sub.forEach((s) => next.delete(s)); + } else { + sub.forEach((s) => next.add(s)); + const pm = parentMap(tree, rootSite); + let p = pm.get(site); + while (p && p !== rootSite) { next.add(p); p = pm.get(p); } + } + return next; +} + +// Columns that must be expanded to reveal cells at the given target sites: each +// site's ancestors up to (but excluding) the root. The sites themselves need no +// expansion — only their ancestor columns must be open for them to show. +export function ancestorsToExpand(tree, rootSite, sites) { + const pm = parentMap(tree, rootSite); + const out = new Set(); + sites.forEach((site) => { + let p = pm.get(site); + while (p && p !== rootSite) { out.add(p); p = pm.get(p); } + }); + return out; +} + +// The source a linked site pulls from for a page: the nearest ancestor holding +// a detached copy (per already-loaded cells), else the root site. +export function effectiveSource(page, targetSite, pm, cells, rootSite) { + const lookup = (site) => cells.get(cellKey(page.path, site)); + return resolveSource(targetSite, pm, lookup, rootSite, page.lastModified); +} + +// Link category of a page from whether it has a detached copy and whether any +// ancestor source exists: linked (no copy) | detached (copy + source) | local +// (copy, no source anywhere above). +export function deriveCategory({ isDetached, sourceExists }) { + if (!isDetached) return 'linked'; + return sourceExists ? 'detached' : 'local'; +} + +// A detached copy is behind its source when the source changed after the copy +// was last modified (beyond the publish-lag grace window). +export const isOutOfSync = resolveOutOfSync; + +// Included downward cells matching a scope. +// scope: 'linked' (publish / detach) | 'detached' (sync / reconnect). +export function scopedCells(allColumns, pages, included, cells, scope) { + const out = []; + const targets = allColumns.filter((c) => included.has(c.site)); + pages.forEach((page) => { + targets.forEach((col) => { + const cell = cells.get(cellKey(page.path, col.site)); + if (!cell) return; + const match = scope === 'detached' ? cell.isDetached : !cell.isDetached; + if (match) out.push({ page, targetSite: col.site }); + }); + }); + return out; +} + +// Source-view pages matching a scope, by link category. +// scope: 'linked' (publish / detach) | 'detached' (sync / reconnect). +export function scopedPagesUp(pages, rows, scope) { + return pages.filter((p) => rows.get(p.path)?.category === scope); +} + +// Group in-scope downward work into valid bulk-action calls — one target per +// group, the pages that share its resolved source — so sync / cancel pull from +// the right base at any tree depth. +export function downGroups({ + tree, pages, allColumns, included, cells, rootSite, scope, +}) { + const pm = parentMap(tree, rootSite); + const groups = new Map(); + scopedCells(allColumns, pages, included, cells, scope).forEach(({ page, targetSite }) => { + const source = effectiveSource(page, targetSite, pm, cells, rootSite).site; + const key = `${targetSite}|${source}`; + if (!groups.has(key)) groups.set(key, { target: targetSite, source, pages: [] }); + groups.get(key).pages.push(page); + }); + return [...groups.values()]; +} + +// Source view: target is always the current site; source is each page's +// resolved nearest ancestor with content (already computed in `rows`). +export function upGroups({ + pages, rows, scope, base, target, +}) { + const groups = new Map(); + scopedPagesUp(pages, rows, scope).forEach((page) => { + const source = rows.get(page.path)?.source || base; + const key = source || '_'; + if (!groups.has(key)) groups.set(key, { target, source, pages: [] }); + groups.get(key).pages.push(page); + }); + return [...groups.values()]; +} + +// Whether every (page, column) matrix cell has loaded — actions must wait for +// this so a click can't act on only the subset resolved so far (an unloaded +// cell is silently skipped, and source resolution walks loaded ancestor cells). +export function matrixComplete(pages, allColumns, cells) { + return pages.every((p) => allColumns.every((c) => cells.has(cellKey(p.path, c.site)))); +} + +// Whether every source-view row has loaded — the upward-view counterpart. +export function sourceComplete(pages, rows) { + return pages.every((p) => rows.has(p.path)); +} + +// Decide what the action panel should load when its selection changes. The +// context key is `org|site`; a context change is always a full reset (selection +// is single-site). Within a context, only newly-added pages load — removed +// pages keep their already-loaded data (instant re-add, no refetch). Returns the +// per-view page lists to load (empty when that role/view doesn't apply). +// - 'noop' nothing changed +// - 'reset' context changed → load all selected pages per role +// - 'incremental' selection changed → load only pages not already loaded +export function planSelectionLoad({ + prevContextKey, prevSelKey, contextKey, selKey, + pages, loadedDownPaths, loadedUpPaths, hasSource, hasLinked, +}) { + if (contextKey === prevContextKey && selKey === prevSelKey) { + return { kind: 'noop', downPages: [], upPages: [] }; + } + if (contextKey !== prevContextKey) { + return { + kind: 'reset', + downPages: hasSource ? pages : [], + upPages: hasLinked ? pages : [], + }; + } + return { + kind: 'incremental', + downPages: hasSource ? pages.filter((p) => !loadedDownPaths.has(p.path)) : [], + upPages: hasLinked ? pages.filter((p) => !loadedUpPaths.has(p.path)) : [], + }; +} diff --git a/tools/apps/msm/helpers/api.js b/tools/apps/msm/helpers/api.js new file mode 100644 index 0000000..29dc115 --- /dev/null +++ b/tools/apps/msm/helpers/api.js @@ -0,0 +1,212 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console */ + +// App-facing API surface. Shared MSM behavior lives in ../core and is +// re-exported here; this file adds only app-specific concerns (folder +// browsing, bulk execution) that the dialog doesn't need. + +import { daFetch, DA_ORIGIN } from '../core/fetch.js'; +import { getSourceChain } from '../core/config.js'; +import { + previewPage, + publishPage, + copyFromSource, + deleteCopy, + mergeFromSource, +} from '../core/operations.js'; +import { getPageStatus } from '../core/status.js'; + +export { + fetchMsmConfig, + getAllMsmSites, + getSiteRoles, + clearMsmCache, +} from '../core/config.js'; +export { + previewPage, + publishPage, + copyFromSource, + deleteCopy, + mergeFromSource, +} from '../core/operations.js'; +export { getPageStatus, getStatusConfig, getPageTimestamp } from '../core/status.js'; +export { PUBLISH_LAG_MS } from '../core/fetch.js'; + +const MAX_CONCURRENT = 5; + +export const ACTIONABLE_EXTENSIONS = new Set(['html', 'json', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf']); + +export function isActionableItem(item) { + return !item.isFolder && !item.isSite && ACTIONABLE_EXTENSIONS.has(item.ext); +} + +function stripExtension(filePath) { + return filePath.replace(/\.[^/.]+$/, ''); +} + +function getExtension(filePath) { + const match = filePath.match(/\.([^/.]+)$/); + return match ? match[1] : ''; +} + +// ────────────────────────────────────────────── +// Concurrency limiter for bulk operations +// ────────────────────────────────────────────── + +/* eslint-disable no-restricted-syntax, no-await-in-loop */ +async function runWithConcurrency(tasks, limit = MAX_CONCURRENT) { + const results = []; + const executing = new Set(); + for (const task of tasks) { + const p = task().then((r) => { executing.delete(p); return r; }); + executing.add(p); + results.push(p); + if (executing.size >= limit) await Promise.race(executing); + } + return Promise.allSettled(results); +} +/* eslint-enable no-restricted-syntax, no-await-in-loop */ + +// ────────────────────────────────────────────── +// Folder listing via DA Admin (app-only) +// ────────────────────────────────────────────── + +export async function listFolder(org, site, path = '/') { + const cleanPath = path.startsWith('/') ? path : `/${path}`; + const url = `${DA_ORIGIN}/list/${org}/${site}${cleanPath}`; + // no-store: admin.da.live sends no cache-control, so the browser would + // heuristic-cache listings and miss content changes after an MSM action. + const resp = await daFetch(url, { cache: 'no-store' }); + if (!resp.ok) return []; + const items = await resp.json(); + const prefix = `/${org}/${site}`; + return items.map((item) => { + let itemPath = item.path || `${cleanPath === '/' ? '' : cleanPath}/${item.name}`; + if (itemPath.startsWith(prefix)) itemPath = itemPath.substring(prefix.length) || '/'; + return { + name: item.name, + path: itemPath, + ext: item.ext || (item.name.includes('.') ? item.name.split('.').pop() : null), + isFolder: !item.ext && !item.name.includes('.'), + lastModified: item.lastModified || null, + }; + }); +} + +// Lists a folder for a linked site and merges in inherited entries from the +// source chain. Adds these fields to every item: +// - sourceSite : where the file actually lives (current site or ancestor) +// - linkedFrom : null when it's a local copy, ancestor site name when linked +// - shadowsSource : true iff the path exists locally AND in an ancestor +// - sourceLastModified : nearest ancestor's lastModified (when it shadows one), so +// callers can compute behind-source without extra requests +// Closest-source-wins: the level nearest the current site decides the source. +export async function listFolderWithInheritance(org, site, path, msmConfig) { + const chain = msmConfig ? getSourceChain(msmConfig, site) : []; + if (!chain.length) { + const items = await listFolder(org, site, path); + return items.map((i) => ({ + ...i, sourceSite: site, linkedFrom: null, shadowsSource: false, sourceLastModified: null, + })); + } + + // walk[0] = self; walk[1..] = ancestors in nearest-first order. + const walk = [{ site }, ...chain.slice().reverse()]; + const lists = await Promise.all( + walk.map((node) => listFolder(org, node.site, path).catch(() => [])), + ); + + const ancestorLM = (itemPath) => { + for (let i = 1; i < lists.length; i += 1) { + const hit = lists[i].find((it) => it.path === itemPath); + if (hit) return hit.lastModified || null; + } + return null; + }; + + const seen = new Map(); + lists.forEach((items, idx) => { + const node = walk[idx]; + const isLocal = idx === 0; + items.forEach((item) => { + if (seen.has(item.path)) return; + const shadowsSource = isLocal + && lists.slice(1).some((arr) => arr.some((i) => i.path === item.path)); + seen.set(item.path, { + ...item, + site, + sourceSite: node.site, + linkedFrom: isLocal ? null : node.site, + shadowsSource, + sourceLastModified: shadowsSource ? ancestorLM(item.path) : null, + }); + }); + }); + + return [...seen.values()]; +} + +// ────────────────────────────────────────────── +// Bulk action executor +// ────────────────────────────────────────────── + +export async function executeBulkAction({ + org, sourceSite, pages, targets, action, syncMode, onPageStatus, +}) { + const targetEntries = Object.entries(targets); + + const tasks = pages.flatMap((page) => { + const ext = getExtension(page.path) || 'html'; + const pagePath = stripExtension(page.path); + + targetEntries.forEach(([targetSite]) => onPageStatus?.(`${page.path}:${targetSite}`, 'queued')); + + return targetEntries.map(([targetSite]) => async () => { + const key = `${page.path}:${targetSite}`; + onPageStatus?.(key, 'pending'); + try { + let result; + switch (action) { + case 'preview': + result = await previewPage(org, targetSite, pagePath, ext); + break; + case 'publish': { + // AEM requires a current preview before publishing to live. + const pv = await previewPage(org, targetSite, pagePath, ext); + result = pv?.error ? pv : await publishPage(org, targetSite, pagePath, ext); + break; + } + case 'detach': + result = await copyFromSource(org, sourceSite, targetSite, pagePath, ext); + break; + case 'sync': + result = syncMode === 'merge' + ? await mergeFromSource(org, sourceSite, targetSite, pagePath, ext) + : await copyFromSource(org, sourceSite, targetSite, pagePath, ext); + break; + case 'reconnect': { + const status = await getPageStatus(org, targetSite, pagePath, null, ext); + result = await deleteCopy(org, targetSite, pagePath, ext); + if (!result?.error) { + if (status.liveState !== 'not-published') { + await previewPage(org, targetSite, pagePath, ext); + await publishPage(org, targetSite, pagePath, ext); + } else if (status.previewState !== 'not-published') { + await previewPage(org, targetSite, pagePath, ext); + } + } + break; + } + default: + result = { error: `Unknown action: ${action}` }; + } + onPageStatus?.(key, result?.error ? 'error' : 'success', result?.error); + return { key, status: result?.error ? 'error' : 'success', error: result?.error }; + } catch (e) { + onPageStatus?.(key, 'error', e.message); + return { key, status: 'error', error: e.message }; + } + }); + }); + + return runWithConcurrency(tasks, MAX_CONCURRENT); +} diff --git a/tools/apps/msm/helpers/column-browser.css b/tools/apps/msm/helpers/column-browser.css new file mode 100644 index 0000000..8c69c1f --- /dev/null +++ b/tools/apps/msm/helpers/column-browser.css @@ -0,0 +1,381 @@ +:host { + display: block; +} + +/* ===== Browser container ===== */ + +.browser { + display: flex; + border: 1px solid var(--s2-gray-200); + border-radius: 8px; + overflow: auto; + min-height: 320px; + max-height: 480px; + background: + linear-gradient(to bottom, + var(--s2-gray-75, #f8f8f8) 32px, + var(--s2-gray-200) 32px, + var(--s2-gray-200) 33px, + var(--s2-gray-50, #fafafa) 33px); +} + +.browser::-webkit-scrollbar { height: 5px; } +.browser::-webkit-scrollbar-track { background: transparent; } + +.browser::-webkit-scrollbar-thumb { + background: var(--s2-gray-300); + border-radius: 3px; +} + +.browser::-webkit-scrollbar-thumb:hover { + background: var(--s2-gray-500); +} + +/* ===== Column ===== */ + +.column { + flex: 0 0 220px; + min-width: 220px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--s2-gray-200); + overflow: hidden; +} + +.column:last-child { + border-right: none; +} + +.column-header { + display: flex; + align-items: center; + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--s2-gray-500); + background: var(--s2-gray-75, #f8f8f8); + border-bottom: 1px solid var(--s2-gray-200); + user-select: none; + min-height: 32px; + box-sizing: border-box; +} + +.column-items { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.column-items::-webkit-scrollbar { width: 5px; } +.column-items::-webkit-scrollbar-track { background: transparent; } + +.column-items::-webkit-scrollbar-thumb { + background: var(--s2-gray-300); + border-radius: 3px; +} + +.column-items::-webkit-scrollbar-thumb:hover { + background: var(--s2-gray-500); +} + +/* ===== Item row ===== */ + +.item { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + cursor: pointer; + user-select: none; + transition: background-color 0.1s; + font-size: 14px; + color: var(--s2-gray-800); + min-height: 32px; + box-sizing: border-box; +} + +.item:hover { + background: var(--s2-gray-100, #f5f5f5); +} + +.item.selected { + background: var(--s2-blue-100, #e0ecff); +} + +.item.selected:hover { + background: var(--s2-blue-200, #cce0ff); +} + +/* Ancestor folders on the path to the current column (previous columns) */ +.item.path-ancestor .item-label { + font-weight: 700; + color: var(--s2-gray-900); +} + +.item.path-ancestor.selected { + background: var(--s2-gray-100, #f0f0f0); + border-left: 3px solid var(--s2-gray-700, #4b4b4b); + padding-left: 9px; +} + +.item.path-ancestor.selected:hover { + background: var(--s2-gray-200, #e1e1e1); +} + +.item.focused { + outline: 2px solid var(--s2-blue-900); + outline-offset: -2px; + border-radius: 4px; +} + +.browser:focus { + outline: none; +} + +.browser:focus-visible { + outline: 2px solid var(--s2-blue-900); + outline-offset: 2px; +} + +/* ===== Checkbox (Spectrum 2) ===== */ + +.item input[type="checkbox"] { + appearance: none; + width: 14px; + height: 14px; + margin: 0; + border: 2px solid var(--s2-gray-600); + border-radius: 2px; + cursor: pointer; + flex-shrink: 0; + position: relative; + background: transparent; + transition: background-color 0.13s, border-color 0.13s; +} + +.item input[type="checkbox"]:checked { + background: var(--s2-gray-800); + border-color: var(--s2-gray-800); +} + +.item input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + margin-top: -1px; + width: 4px; + height: 8px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.item input[type="checkbox"]:indeterminate { + background: var(--s2-gray-800); + border-color: var(--s2-gray-800); +} + +.item input[type="checkbox"]:indeterminate::after { + content: ''; + position: absolute; + inset: 0; + margin: auto; + width: 7px; + height: 2px; + background: #fff; + border-radius: 1px; +} + +.item input[type="checkbox"]:focus-visible { + outline: 2px solid var(--s2-blue-900); + outline-offset: 2px; +} + +.item input[type="checkbox"]:hover:not(:disabled) { + border-color: var(--s2-gray-800); +} + +/* ===== Item label and icons ===== */ + +.item-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item-arrow { + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: var(--s2-gray-400); +} + +.item-arrow svg { + width: 14px; + height: 14px; +} + +.item-icon { + position: relative; + display: inline-flex; + align-items: center; + flex-shrink: 0; + color: var(--s2-gray-500); +} + +.item-icon svg { + width: 16px; + height: 16px; +} + +/* Inheritance state badge overlaid on the type icon's corner */ +.inherit-badge { + position: absolute; + right: -5px; + bottom: -4px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px; + border-radius: 50%; + background: var(--s2-gray-50, #fafafa); + color: var(--s2-gray-600); +} + +.inherit-badge svg { + width: 10px; + height: 10px; +} + +/* Local override is the notable exception — give it a touch more weight */ +.inherit-badge.override { + color: var(--s2-gray-900, #292929); +} + +/* Trailing slot: chevron (containers) or status icon (pages) — never both */ +.item-trailing { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 18px; +} + +/* ===== Status icon (action-required) ===== */ + +.row-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 18px; + height: 18px; +} + +.row-icon-loading { + width: 12px; + height: 12px; + margin: 0 3px; + border: 2px solid var(--s2-gray-200); + border-top-color: var(--s2-gray-500); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* ===== Column loading ===== */ + +.column-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--s2-gray-500); + font-size: 13px; + font-style: italic; +} + +.column-loading .mini-spinner { + width: 14px; + height: 14px; + margin-right: 8px; + border: 2px solid var(--s2-gray-200); + border-top-color: var(--s2-gray-600); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ===== Mobile: single-column mode ===== */ + +@media (width <= 600px) { + .browser { + position: relative; + min-height: 360px; + overflow: hidden; + } + + .column { + position: absolute; + inset: 0; + flex: none; + width: 100%; + border-right: none; + background: var(--s2-gray-50, #fafafa); + transform: translateX(100%); + transition: transform 0.25s ease; + visibility: hidden; + z-index: 0; + } + + .column.active { + transform: translateX(0); + visibility: visible; + z-index: 1; + } + + .back-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 0; + margin-right: 8px; + background: none; + border: none; + cursor: pointer; + color: var(--s2-blue-900); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .back-btn svg { + width: 12px; + height: 12px; + } +} + +@media (width >= 601px) { + .back-btn { + display: none; + } +} + +/* ===== Column empty ===== */ + +.column-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--s2-gray-400); + font-size: 13px; + font-style: italic; +} diff --git a/tools/apps/msm/helpers/column-browser.js b/tools/apps/msm/helpers/column-browser.js new file mode 100644 index 0000000..69f6a63 --- /dev/null +++ b/tools/apps/msm/helpers/column-browser.js @@ -0,0 +1,784 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ +import { LitElement, html, nothing } from 'da-lit'; +import { + listFolder, + listFolderWithInheritance, + isActionableItem, + getAllMsmSites, + getPageStatus, + getStatusConfig, + PUBLISH_LAG_MS, +} from './api.js'; +import { icon } from '../core/icons.js'; + +const NX = 'https://da.live/nx'; +let sl; +let sheet; +try { + const { default: getStyle } = await import(`${NX}/utils/styles.js`); + [sl, sheet] = await Promise.all([ + getStyle(`${NX}/public/sl/styles.css`), + getStyle(import.meta.url), + ]); +} catch (e) { + console.warn('Failed to load column-browser styles:', e); +} + +const GLOBE_ICON = icon('S2_Icon_GlobeGrid_20_N'); +const FOLDER_ICON = icon('S2_Icon_Folder_20_N'); +const PAGE_ICON = icon('S2_Icon_File_20_N'); +const ARROW_RIGHT = icon('S2_Icon_ChevronRight_20_N', '0 0 20 20', 14, 14); +const BACK_ARROW = icon('S2_Icon_ChevronLeft_20_N', '0 0 20 20', 14, 14); + +const itemKey = (item) => `${item.site || ''}:${item.path}`; +const parseKey = (key) => { + const idx = key.indexOf(':'); + return { site: key.slice(0, idx), path: key.slice(idx + 1) }; +}; + +class MsmColumnBrowser extends LitElement { + static properties = { + org: { type: String }, + msmConfig: { attribute: false }, + initialSite: { type: String }, + initialPath: { type: String }, + _columns: { state: true }, + _selectedPages: { state: true }, + _checkedContainers: { state: true }, + _rowStatus: { state: true }, + _activeColumnIdx: { state: true }, + _loadingColumn: { state: true }, + _focusedItem: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [sl, sheet].filter(Boolean); + this._columns = []; + this._selectedPages = new Map(); + this._checkedContainers = new Set(); + this._rowStatus = new Map(); + this._activeColumnIdx = 0; + this._loadingColumn = -1; + this._focusedItem = null; + this._mergedFolderCache = new Map(); + // Per-container crawl generation; bumping it cancels an in-flight crawl. + this._crawlGen = new Map(); + this._initKey = null; + this._handleKeydown = this._onKeydown.bind(this); + this._maybeInit(); + } + + updated(changed) { + if (changed.has('org') || changed.has('msmConfig') + || changed.has('initialSite') || changed.has('initialPath')) { + this._maybeInit(); + } + } + + _maybeInit() { + if (!this.org || !this.msmConfig) return; + const key = `${this.org}|${this.initialSite || ''}|${this.initialPath || ''}`; + if (key === this._initKey) return; + this._initKey = key; + this._selectedPages = new Map(); + this._checkedContainers = new Set(); + this._rowStatus = new Map(); + this._crawlGen = new Map(); + this._focusedItem = null; + this._activeColumnIdx = 0; + this.initSitesColumn(); + if (this.initialSite) this._openDeepLink(); + } + + invalidateMergedCache() { + this._mergedFolderCache = new Map(); + } + + // Called after the action panel mutates content (detach/reconnect/sync): + // the cached merged listings and per-row statuses are now stale. Drop both + // and re-list every navigated column so link badges + status icons reflect + // the change. Selection and column path are preserved (paths don't move — + // only a page's link state / timestamp changes). + async refreshContent() { + this.invalidateMergedCache(); + this._rowStatus = new Map(); + const cols = [...this._columns]; + /* eslint-disable no-await-in-loop */ + for (let i = 1; i < cols.length; i += 1) { + const parent = cols[i - 1]; + const parentItem = parent.items.find((it) => it.path === parent.selectedPath); + if (parentItem?.site) { + const listPath = parentItem.isSite ? '/' : parentItem.path; + const raw = await this._loadFolderItems(parentItem.site, listPath).catch(() => []); + const items = raw.map((it) => ({ ...it, site: it.site || parentItem.site })); + cols[i] = { ...cols[i], items }; + } + } + /* eslint-enable no-await-in-loop */ + this._columns = cols; + cols.forEach((c) => this._loadRowStatuses(c.items)); + } + + _siteHasSource(site) { + if (!this.msmConfig || !site) return false; + return (this.msmConfig.rows || []).some((row) => (row.satellite ?? row.linked) === site); + } + + async _loadFolderItems(site, path) { + if (this._siteHasSource(site)) { + const cacheKey = `${site}::${path}`; + if (this._mergedFolderCache.has(cacheKey)) return this._mergedFolderCache.get(cacheKey); + const items = await listFolderWithInheritance(this.org, site, path, this.msmConfig); + this._mergedFolderCache.set(cacheKey, items); + return items; + } + return listFolder(this.org, site, path); + } + + // ── Sites column ────────────────────────────────────────────────────────── + + initSitesColumn() { + const sites = getAllMsmSites(this.msmConfig); + const items = sites.map((s) => ({ + name: s.label, + path: s.site, + site: s.site, + isSite: true, + isFolder: false, + })); + this._columns = [{ header: 'Sites', items, selectedPath: null }]; + } + + // ── Deep linking ──────────────────────────────────────────────────────── + + async _openDeepLink() { + const siteItem = this._columns[0]?.items.find((it) => it.site === this.initialSite); + if (!siteItem) { + this._dispatchDeepLinkWarning(this.initialSite, ''); + return; + } + await this.navigateToFolder(0, siteItem); + if (this.initialPath) await this._walkPath(this.initialPath); + await this.scrollToActiveColumn(); + await this.scrollSelectionIntoView({ block: 'center', behavior: 'smooth' }); + } + + async _walkPath(path) { + const normalized = path.startsWith('/') ? path : `/${path}`; + const parts = normalized.split('/').filter(Boolean); + let colIdx = 1; // column 1 is the site root opened by _openDeepLink + let cum = ''; + let lastResolved = ''; + let resolved = parts.length === 0; + + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < parts.length; i += 1) { + cum += `/${parts[i]}`; + const stepPath = cum; + const col = this._columns[colIdx]; + if (!col) break; + const isLast = i === parts.length - 1; + let item = col.items.find((it) => it.path === stepPath); + if (!item && isLast && !/\.[a-z0-9]+$/i.test(parts[i])) { + item = col.items.find((it) => it.path === `${stepPath}.html`); + } + if (!item) break; + lastResolved = item.path; + + if (isLast) { + if (item.isFolder) { + await this.navigateToFolder(colIdx, item); + } else if (this.showCheckbox(item)) { + this._togglePage(item); + this._setFocus(colIdx, item); + } + resolved = true; + } else if (item.isFolder) { + await this.navigateToFolder(colIdx, item); + colIdx += 1; + } else { + break; + } + } + /* eslint-enable no-await-in-loop */ + + if (!resolved) this._dispatchDeepLinkWarning(path, lastResolved); + } + + _dispatchDeepLinkWarning(requestedPath, lastResolvedPath) { + this.dispatchEvent(new CustomEvent('deep-link-warning', { + detail: { requestedPath, lastResolvedPath }, bubbles: true, composed: true, + })); + } + + // Programmatically select a set of leaf pages at a site (used by the action + // panel's "manage in base" hand-off). Opens the site, resolves each path to + // its real leaf item, replaces the selection, and emits it. + async selectPaths(site, paths) { + if (!site || !paths?.length) return; + const siteItem = this._columns[0]?.items.find((it) => it.site === site); + if (!siteItem) return; + await this.navigateToFolder(0, siteItem); + const resolved = await Promise.all(paths.map((p) => this._resolveLeaf(site, p))); + this._crawlGen.forEach((v, k) => this._crawlGen.set(k, v + 1)); + const selected = new Map(); + resolved.forEach((leaf) => { if (leaf) selected.set(itemKey(leaf), leaf); }); + this._selectedPages = selected; + this._checkedContainers = new Set(); + this.emitSelection(site); + } + + // Remove a single page from the selection (used by the action panel's + // per-row remove control). Mirrors the deselect branch of _togglePage. + deselectPath(site, path) { + const key = `${site}:${path}`; + if (!this._selectedPages.has(key)) return; + const pages = new Map(this._selectedPages); + pages.delete(key); + const drop = new Set(this._ancestorContainerKeys(site, path)); + const containers = new Set(); + this._checkedContainers.forEach((k) => { if (!drop.has(k)) containers.add(k); }); + this._selectedPages = pages; + this._checkedContainers = containers; + this.emitSelection(site); + } + + // Resolve a site-root-relative page path to its leaf item by listing the + // folder it lives in. Returns null when the page doesn't exist on that site. + async _resolveLeaf(site, path) { + const idx = path.lastIndexOf('/'); + const parentPath = idx > 0 ? path.slice(0, idx) : '/'; + const items = await this._loadFolderItems(site, parentPath).catch(() => []); + const leaf = items.find((it) => it.path === path); + return leaf ? { ...leaf, site: leaf.site || site } : null; + } + + // ── Navigation ──────────────────────────────────────────────────────────── + + async navigateToFolder(colIdx, item) { + const { site } = item; + if (!site) return; + + const newColumns = this._columns.slice(0, colIdx + 1); + newColumns[colIdx] = { ...newColumns[colIdx], selectedPath: item.path }; + this._columns = newColumns; + this._activeColumnIdx = colIdx + 1; + this._loadingColumn = colIdx + 1; + + let items = []; + try { + const raw = await this._loadFolderItems(site, item.isSite ? '/' : item.path); + items = raw.map((i) => ({ ...i, site: i.site || site })); + } catch (e) { + console.error('Failed to load folder:', e); + } + + this._columns = [...newColumns, { header: item.name, items, selectedPath: null }]; + this._loadingColumn = -1; + this.scrollToActiveColumn(); + this._loadRowStatuses(items); + } + + getCurrentSite() { + const cols = [...this._columns].reverse(); + const match = cols.reduce((found, col) => ( + found || col.items.find((item) => item.site) + ), null); + return match?.site || null; + } + + handleItemClick(colIdx, item, e) { + if (e?.target?.type === 'checkbox') return; + if (item.isFolder || item.isSite) { + this.navigateToFolder(colIdx, item); + } else if (this.showCheckbox(item)) { + this._togglePage(item); + } + } + + goBack() { + if (this._activeColumnIdx > 0) this._activeColumnIdx -= 1; + } + + // ── Selection model ───────────────────────────────────────────────────── + // `_selectedPages` (Map) is the canonical set of selected leaf + // pages — this is what gets emitted. `_checkedContainers` (Set) marks + // sites/folders the user (or a crawl) has fully selected, for optimistic and + // post-crawl "checked" display. A checked container shows checked instantly; + // a background crawl then fills in the exact leaf pages. + + showCheckbox(item) { + if (item.isSite || item.isFolder) return true; + return isActionableItem(item); + } + + isItemChecked(item) { + if (item.isSite || item.isFolder) return this._checkedContainers.has(itemKey(item)); + return this._selectedPages.has(itemKey(item)); + } + + // 'checked' | 'indeterminate' | 'unchecked' + checkboxState(item, colIdx) { + if (!(item.isSite || item.isFolder)) { + return this._selectedPages.has(itemKey(item)) ? 'checked' : 'unchecked'; + } + if (this._checkedContainers.has(itemKey(item))) return 'checked'; + const derived = this._deriveOpenState(item, colIdx); + if (derived) return derived; + return this._hasSelectedUnder(item) ? 'indeterminate' : 'unchecked'; + } + + // When a container's child column is open we can reflect an exact state from + // what's loaded, without crawling — the "show what we can, fast" path. + _deriveOpenState(item, colIdx) { + const parentCol = this._columns[colIdx]; + if (!parentCol || parentCol.selectedPath !== item.path) return null; + const child = this._columns[colIdx + 1]; + if (!child) return null; + const selectable = child.items.filter((it) => it.isFolder || isActionableItem(it)); + if (!selectable.length) return null; + let checked = 0; + let unchecked = 0; + selectable.forEach((it) => { + let st; + if (it.isFolder) st = this.checkboxState(it, colIdx + 1); + else st = this._selectedPages.has(itemKey(it)) ? 'checked' : 'unchecked'; + if (st === 'checked') checked += 1; + else if (st === 'unchecked') unchecked += 1; + }); + if (checked === selectable.length) return 'checked'; + if (unchecked === selectable.length) return 'unchecked'; + return 'indeterminate'; + } + + _isUnder(key, item) { + const { site, path } = parseKey(key); + if (site !== item.site) return false; + if (item.isSite) return path !== item.site; + return path.startsWith(`${item.path}/`); + } + + _hasSelectedUnder(item) { + return Array.from(this._selectedPages.keys()).some((key) => this._isUnder(key, item)); + } + + // Site row + parent folder keys for a given path (excludes the path itself). + _ancestorContainerKeys(site, path) { + const segs = path.split('/').filter(Boolean); + const keys = [`${site}:${site}`]; + for (let i = 1; i < segs.length; i += 1) { + keys.push(`${site}:/${segs.slice(0, i).join('/')}`); + } + return keys; + } + + // MSM actions are defined relative to one site's position in the inheritance + // tree, so a selection must stay within a single site. Selecting on a new + // site clears whatever was selected on the previous one. + _clearIfOtherSite(site) { + const isOther = (k) => parseKey(k).site !== site; + const hasOther = [...this._selectedPages.keys()].some(isOther) + || [...this._checkedContainers].some(isOther); + if (!hasOther) return; + this._crawlGen.forEach((v, k) => this._crawlGen.set(k, v + 1)); + this._selectedPages = new Map(); + this._checkedContainers = new Set(); + } + + _togglePage(item) { + const key = itemKey(item); + if (!this._selectedPages.has(key)) this._clearIfOtherSite(item.site); + const pages = new Map(this._selectedPages); + if (pages.has(key)) { + pages.delete(key); + const drop = new Set(this._ancestorContainerKeys(item.site, item.path)); + const containers = new Set(); + this._checkedContainers.forEach((k) => { if (!drop.has(k)) containers.add(k); }); + this._checkedContainers = containers; + } else { + pages.set(key, item); + } + this._selectedPages = pages; + this.emitSelection(item.site); + } + + toggleCheck(item, colIdx) { + if (item.isSite || item.isFolder) { + if (this.checkboxState(item, colIdx) === 'checked') this._unselectSubtree(item); + else this._selectSubtree(item); + this.emitSelection(item.site); + } else { + this._togglePage(item); + } + } + + _selectSubtree(item) { + this._clearIfOtherSite(item.site); + const rootKey = itemKey(item); + this._checkedContainers = new Set(this._checkedContainers).add(rootKey); + const gen = (this._crawlGen.get(rootKey) || 0) + 1; + this._crawlGen.set(rootKey, gen); + this._crawlInto(item.site, item.isSite ? '/' : item.path, rootKey, gen); + } + + _unselectSubtree(item) { + const rootKey = itemKey(item); + // Cancel any crawl still adding pages under this container. + this._crawlGen.set(rootKey, (this._crawlGen.get(rootKey) || 0) + 1); + + const pages = new Map(); + this._selectedPages.forEach((v, k) => { if (!this._isUnder(k, item)) pages.set(k, v); }); + this._selectedPages = pages; + + const drop = new Set([rootKey, ...this._ancestorContainerKeys(item.site, item.path)]); + const containers = new Set(); + this._checkedContainers.forEach((k) => { + if (drop.has(k)) return; + if (this._isUnder(k, item)) return; + containers.add(k); + }); + this._checkedContainers = containers; + } + + // Background recursive crawl: lists each folder, marks it checked, and adds + // its actionable pages to the selection — emitting as it goes so the UI fills + // in. A generation mismatch (the user unchecked the root) aborts the walk. + async _crawlInto(site, path, rootKey, gen) { + if (this._crawlGen.get(rootKey) !== gen) return; + let items; + try { + items = await this._loadFolderItems(site, path); + } catch { + return; + } + if (this._crawlGen.get(rootKey) !== gen) return; + + const pages = new Map(this._selectedPages); + const containers = new Set(this._checkedContainers); + const folders = []; + items.forEach((it) => { + const isite = it.site || site; + if (it.isFolder) { + folders.push({ ...it, site: isite }); + containers.add(`${isite}:${it.path}`); + } else if (isActionableItem(it)) { + pages.set(`${isite}:${it.path}`, { ...it, site: isite }); + } + }); + this._selectedPages = pages; + this._checkedContainers = containers; + this.emitSelection(site); + + /* eslint-disable no-await-in-loop, no-restricted-syntax */ + for (const folder of folders) { + if (this._crawlGen.get(rootKey) !== gen) return; + await this._crawlInto(folder.site, folder.path, rootKey, gen); + } + /* eslint-enable no-await-in-loop, no-restricted-syntax */ + } + + emitSelection(site) { + const selectedItems = [...this._selectedPages.values()]; + const lastCol = this._columns[this._columns.length - 1]; + const currentPath = lastCol?.header || ''; + this.dispatchEvent(new CustomEvent('browse-selection', { + detail: { selectedItems, currentPath, site: site || this.getCurrentSite() }, + bubbles: true, + composed: true, + })); + } + + handleCheckChange(item, e, colIdx) { + e.stopPropagation(); + this.toggleCheck(item, colIdx); + } + + // ── Lazy per-row status (icon2) ─────────────────────────────────────────── + + _loadRowStatuses(items) { + const next = new Map(this._rowStatus); + const toFetch = []; + items.forEach((item) => { + if (!isActionableItem(item)) return; + // The status icon answers "do you need an MSM action relative to your + // source", so it only applies where inheritance does. Base-only sites + // have no source — skip the fetch entirely. + if (!this._siteHasSource(item.site)) return; + const key = itemKey(item); + if (next.has(key)) return; + next.set(key, 'loading'); + toFetch.push(item); + }); + if (!toFetch.length) return; + this._rowStatus = next; + + toFetch.forEach((item) => { + const ext = item.ext || 'html'; + const pagePath = item.path.replace(/\.[^/.]+$/, ''); + getPageStatus(this.org, item.site, pagePath, item.lastModified, ext) + .then((status) => this._setRowStatus(itemKey(item), status)) + .catch(() => this._setRowStatus(itemKey(item), { previewState: 'not-published', liveState: 'not-published' })); + }); + } + + _setRowStatus(key, status) { + const m = new Map(this._rowStatus); + m.set(key, status); + this._rowStatus = m; + } + + // ── Keyboard navigation ───────────────────────────────────────────────── + + _setFocus(columnIdx, item) { + this._focusedItem = item + ? { columnIdx, path: item.path, site: item.site || '' } + : null; + } + + _getActiveFocusedIdx() { + const f = this._focusedItem; + if (!f || f.columnIdx !== this._activeColumnIdx) return -1; + const col = this._columns[this._activeColumnIdx]; + if (!col) return -1; + return col.items.findIndex((it) => it.path === f.path && (it.site || '') === f.site); + } + + _clearFocusIfMatches(item) { + const f = this._focusedItem; + if (f && f.path === item.path && f.site === (item.site || '')) this._focusedItem = null; + } + + _onKeydown(e) { + const col = this._columns[this._activeColumnIdx]; + if (!col || !col.items.length) return; + const { items } = col; + const curIdx = this._getActiveFocusedIdx(); + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + this._setFocus(this._activeColumnIdx, items[Math.min(curIdx + 1, items.length - 1)]); + this.scrollFocusedIntoView(); + break; + } + case 'ArrowUp': { + e.preventDefault(); + this._setFocus(this._activeColumnIdx, items[Math.max(curIdx - 1, 0)]); + this.scrollFocusedIntoView(); + break; + } + case 'ArrowRight': + case 'Enter': { + e.preventDefault(); + const item = items[curIdx]; + if (item && (item.isFolder || item.isSite)) { + const fromColumn = this._activeColumnIdx; + this.navigateToFolder(fromColumn, item).then(() => { + const newCol = this._columns[fromColumn + 1]; + if (newCol?.items.length) { + this._setFocus(fromColumn + 1, newCol.items[0]); + this.scrollFocusedIntoView(); + } + }); + } + break; + } + case 'ArrowLeft': { + e.preventDefault(); + if (this._activeColumnIdx > 0) { + this._activeColumnIdx -= 1; + const prevCol = this._columns[this._activeColumnIdx]; + this._setFocus(this._activeColumnIdx, prevCol?.items[0] || null); + } + break; + } + case ' ': { + e.preventDefault(); + const item = items[curIdx]; + if (item && this.showCheckbox(item)) this.toggleCheck(item, this._activeColumnIdx); + break; + } + default: + break; + } + } + + // ── Scrolling helpers ───────────────────────────────────────────────────── + + _findItemElement(colIdx, path, site = '') { + const lists = this.shadowRoot.querySelectorAll('.column .column-items'); + const list = lists[colIdx]; + if (!list) return null; + return Array.from(list.querySelectorAll('.item')).find((el) => ( + el.dataset.path === path && (el.dataset.site || '') === (site || '') + )) || null; + } + + scrollItemIntoView(colIdx, path, site = '', { block = 'nearest', behavior = 'auto' } = {}) { + return this.updateComplete.then(() => new Promise((resolve) => { + requestAnimationFrame(() => { + const target = this._findItemElement(colIdx, path, site); + if (target) target.scrollIntoView({ block, behavior, inline: 'nearest' }); + resolve(); + }); + })); + } + + scrollFocusedIntoView() { + const f = this._focusedItem; + if (!f) return this.updateComplete; + return this.scrollItemIntoView(f.columnIdx, f.path, f.site); + } + + scrollSelectionIntoView({ block = 'center', behavior = 'smooth' } = {}) { + if (this._focusedItem) { + const { columnIdx, path, site } = this._focusedItem; + return this.scrollItemIntoView(columnIdx, path, site, { block, behavior }); + } + for (let c = this._columns.length - 1; c >= 0; c -= 1) { + const checked = this._columns[c].items.find((item) => this.isItemChecked(item)); + if (checked) { + return this.scrollItemIntoView(c, checked.path, checked.site || '', { block, behavior }); + } + } + return this.updateComplete; + } + + scrollToActiveColumn() { + return this.updateComplete.then(() => new Promise((resolve) => { + requestAnimationFrame(() => { + if (window.innerWidth > 600) { + const browser = this.shadowRoot.querySelector('.browser'); + if (browser) browser.scrollTo({ left: browser.scrollWidth, behavior: 'smooth' }); + } + resolve(); + }); + })); + } + + // ── Render ──────────────────────────────────────────────────────────────── + + // Small corner badge overlaid on the type icon. Link state only applies to + // linked-site listings (which carry a `linkedFrom` field) and never to the + // sites column. + renderLinkBadge(item) { + if (item.isSite || !('linkedFrom' in item)) return nothing; + const linked = !!item.linkedFrom; + const tip = linked ? `Linked to ${item.linkedFrom}` : 'Detached (independent copy)'; + return html` + ${icon(linked ? 'S2_Icon_LinkApplied_20_N' : 'S2_Icon_UnLink_20_N')} + `; + } + + renderStatusIcon(item) { + if (!isActionableItem(item)) return nothing; + if (!this._siteHasSource(item.site)) return nothing; + const status = this._rowStatus.get(itemKey(item)); + if (!status) return nothing; + if (status === 'loading') return html``; + const isDetached = !item.linkedFrom; + const outOfSync = !!(item.shadowsSource && item.sourceLastModified && item.lastModified + && new Date(item.sourceLastModified).getTime() + > new Date(item.lastModified).getTime() + PUBLISH_LAG_MS); + const cfg = getStatusConfig({ + isDetached, outOfSync, previewState: status.previewState, liveState: status.liveState, + }); + return html` + ${icon(cfg.name)} + `; + } + + renderItem(colIdx, item) { + const isSelected = this._columns[colIdx]?.selectedPath === item.path; + const isPathAncestor = isSelected && (item.isFolder || item.isSite) + && colIdx < this._columns.length - 1; + const f = this._focusedItem; + const isFocused = !!f && f.columnIdx === colIdx + && f.path === item.path && f.site === (item.site || ''); + const state = this.checkboxState(item, colIdx); + const isContainer = item.isFolder || item.isSite; + + let typeIcon = PAGE_ICON; + if (item.isSite) typeIcon = GLOBE_ICON; + else if (item.isFolder) typeIcon = FOLDER_ICON; + + return html` +
this.handleItemClick(colIdx, item, e)} + role="option" + aria-selected=${isSelected} + > + ${this.showCheckbox(item) ? html` + this.handleCheckChange(item, e, colIdx)} + @click=${(e) => e.stopPropagation()} + /> + ` : nothing} + + ${typeIcon} + ${this.renderLinkBadge(item)} + + ${item.name} + + ${isContainer + ? html`${ARROW_RIGHT}` + : this.renderStatusIcon(item)} + +
+ `; + } + + renderColumn(col, colIdx) { + const isActive = colIdx === this._activeColumnIdx; + const isLoading = this._loadingColumn === colIdx; + + return html` +
+
+ ${colIdx > 0 ? html` + + ` : nothing} + ${col.header} +
+
+ ${isLoading ? html` +
Loading…
+ ` : nothing} + ${!isLoading && col.items.length === 0 ? html` +
Empty folder
+ ` : nothing} + ${!isLoading ? col.items.map((item) => this.renderItem(colIdx, item)) : nothing} +
+
+ `; + } + + render() { + if (!this._columns.length) { + return html`
No sites available
`; + } + const loadingNext = this._loadingColumn === this._columns.length; + return html` +
+ ${this._columns.map((col, idx) => this.renderColumn(col, idx))} + ${loadingNext ? html` +
+
Loading…
+
+
Loading…
+
+
+ ` : nothing} +
+ `; + } +} + +customElements.define('msm-column-browser', MsmColumnBrowser); diff --git a/tools/apps/msm/msm.css b/tools/apps/msm/msm.css new file mode 100644 index 0000000..f7c7502 --- /dev/null +++ b/tools/apps/msm/msm.css @@ -0,0 +1,148 @@ +:host { + display: block; + max-width: var(--grid-container-width); + margin: var(--spacing-800) auto var(--spacing-800) auto; + -webkit-font-smoothing: antialiased; + font-family: var(--body-font-family); + color: var(--s2-gray-800); +} + +/* ===== Toolbar ===== */ + +.msm-toolbar { + margin-bottom: var(--spacing-400); + padding-bottom: var(--spacing-400); +} + +.msm-toolbar h1 { + font-size: var(--type-heading-xxl-size); + line-height: var(--spectrum-line-height-100); + margin: 0 0 var(--spacing-400); + font-weight: 700; +} + +.msm-toolbar-form { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.msm-toolbar-form sl-input { + flex: 1; +} + +/* ===== Loading / Empty ===== */ + +.msm-loading, +.msm-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + font-size: var(--s2-font-size-200); + color: var(--s2-gray-600); + font-style: italic; +} + +.msm-empty { + flex-direction: column; + gap: var(--spacing-300); + font-style: normal; +} + +.msm-empty p { + margin: 0; +} + +.msm-loading .spinner { + width: 20px; + height: 20px; + margin-right: var(--spacing-200); + border: 2px solid var(--s2-gray-300); + border-top-color: var(--s2-gray-800); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ===== Warning banner ===== */ + +.nx-alert { + border: 2px solid var(--s2-gray-300); + border-radius: 8px; + background: var(--s2-gray-100); + color: var(--s2-gray-900); + margin-bottom: 16px; + padding: 10px 12px; +} + +.nx-alert p { + margin: 0; +} + +.nx-alert.warning { + border-color: var(--s2-orange-500); + background: var(--s2-orange-100); +} + +.deep-link-warning { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.deep-link-warning .nx-alert-dismiss { + appearance: none; + background: transparent; + border: none; + color: var(--s2-gray-700); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + flex-shrink: 0; +} + +.deep-link-warning .nx-alert-dismiss:hover { + background: rgb(0 0 0 / 5%); + color: var(--s2-gray-900); +} + +.deep-link-warning .nx-alert-dismiss:focus-visible { + outline: 2px solid var(--s2-blue-700, #393dba); + outline-offset: 2px; +} + +/* ===== Layout ===== */ + +.msm-body { + display: flex; + flex-direction: column; + gap: var(--spacing-400); +} + +/* ===== Responsive ===== */ + +@media (width <= 600px) { + :host { + margin: 16px; + } + + .msm-toolbar h1 { + font-size: 20px; + } + + .msm-toolbar-form { + gap: 8px; + } + + .msm-toolbar-form sl-input { + flex: 1; + min-width: 0; + } +} diff --git a/tools/apps/msm/msm.html b/tools/apps/msm/msm.html new file mode 100644 index 0000000..6f429fe --- /dev/null +++ b/tools/apps/msm/msm.html @@ -0,0 +1,21 @@ + + + + MSM Actions + + + + + + + + + + + + + + + diff --git a/tools/apps/msm/msm.js b/tools/apps/msm/msm.js new file mode 100644 index 0000000..a9c8799 --- /dev/null +++ b/tools/apps/msm/msm.js @@ -0,0 +1,237 @@ +/* eslint-disable no-underscore-dangle, import/no-unresolved, no-console, class-methods-use-this */ +import DA_SDK from 'https://da.live/nx/utils/sdk.js'; +import { LitElement, html, nothing } from 'da-lit'; +import { fetchMsmConfig, clearMsmCache } from './helpers/api.js'; +import 'https://da.live/nx/public/sl/components.js'; +import './helpers/column-browser.js'; +import './helpers/action-panel.js'; + +const NX = 'https://da.live/nx'; +let sl = null; +let styles = null; +let buttons = null; +try { + const { default: getStyle } = await import(`${NX}/utils/styles.js`); + [sl, styles, buttons] = await Promise.all([ + getStyle(`${NX}/public/sl/styles.css`), + getStyle(import.meta.url), + getStyle(`${NX}/styles/buttons.css`), + ]); +} catch (e) { + console.warn('Failed to load styles:', e); +} + +// Parse `?org=&site=&path=` deep-link params (the dialog links here with these). +function parseDeepLink() { + const params = new URLSearchParams(window.location.search); + const org = (params.get('org') || '').trim(); + if (!org) return null; + return { + org, + site: (params.get('site') || '').trim(), + path: (params.get('path') || '').trim(), + }; +} + +// Split "/org", "/org/site", or "/org/site/path…" into parts. +function parsePathInput(raw) { + const parts = (raw || '').trim().replace(/^\/+/, '').split('/').filter(Boolean); + const [org, site, ...rest] = parts; + return { org: org || '', site: site || '', path: rest.length ? `/${rest.join('/')}` : '' }; +} + +class MsmApp extends LitElement { + static properties = { + context: { attribute: false }, + token: { attribute: false }, + deepLink: { attribute: false }, + _state: { state: true }, + _org: { state: true }, + _initialSite: { state: true }, + _initialPath: { state: true }, + _msmConfig: { state: true }, + _selectedItems: { state: true }, + _currentSite: { state: true }, + _initError: { state: true }, + _deepLinkWarning: { state: true }, + }; + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [sl, styles, buttons].filter(Boolean); + this._state = 'init'; + this._org = ''; + this._initialSite = ''; + this._initialPath = ''; + this._selectedItems = []; + this._currentSite = ''; + this._initError = ''; + this._deepLinkWarning = ''; + + if (this.deepLink?.org) { + this._org = this.deepLink.org; + this._initialSite = this.deepLink.site || ''; + this._initialPath = this.deepLink.path || ''; + this._state = 'loading'; + this.loadConfig(this.deepLink.org); + } + } + + get _inputValue() { + if (!this._org) return ''; + let value = `/${this._org}`; + if (this._initialSite) value += `/${this._initialSite}`; + if (this._initialSite && this._initialPath) value += this._initialPath; + return value; + } + + async loadConfig(org) { + try { + const config = await fetchMsmConfig(org); + if (!config || !config.sourceSites.length) { + this._state = 'no-config'; + return; + } + this._msmConfig = config; + this._state = 'ready'; + } catch (e) { + console.error('Failed to load MSM config:', e); + this._initError = `Could not load MSM configuration for "${org}".`; + this._state = 'init'; + } + } + + handleSubmit(e) { + e.preventDefault(); + const input = this.shadowRoot.querySelector('#path-input'); + const { org, site, path } = parsePathInput(input?.value); + if (!org) return; + this._org = org; + this._initialSite = site; + this._initialPath = path; + this._initError = ''; + this._deepLinkWarning = ''; + this._selectedItems = []; + this._currentSite = ''; + this._state = 'loading'; + // An explicit Load means "give me fresh" — drop cached org config so an + // edited MSM config sheet is picked up without a hard reload. + clearMsmCache(); + this.loadConfig(org); + } + + handleBrowseSelection(e) { + const { selectedItems, site } = e.detail; + this._selectedItems = selectedItems; + this._currentSite = site; + } + + handleNavigatePages(e) { + const { site, paths } = e.detail || {}; + const browser = this.shadowRoot.querySelector('msm-column-browser'); + browser?.selectPaths(site, paths); + } + + handleDeselectPage(e) { + const { site, path } = e.detail || {}; + const browser = this.shadowRoot.querySelector('msm-column-browser'); + browser?.deselectPath(site, path); + } + + handleContentChanged() { + const browser = this.shadowRoot.querySelector('msm-column-browser'); + browser?.refreshContent(); + } + + handleDeepLinkWarning(e) { + const { requestedPath, lastResolvedPath } = e.detail || {}; + const tail = lastResolvedPath ? ` (navigated as far as ${lastResolvedPath})` : ''; + this._deepLinkWarning = `Could not resolve "${requestedPath}"${tail}.`; + } + + dismissDeepLinkWarning() { + this._deepLinkWarning = ''; + } + + renderToolbar() { + return html` +
+

Multi-Site Management

+
+ + Load +
+
+ `; + } + + renderContent() { + if (this._state === 'init') return nothing; + + if (this._state === 'loading') { + return html`
Loading MSM configuration…
`; + } + + if (this._state === 'no-config') { + return html`

No MSM source sites configured for ${this._org}.

`; + } + + return html` + ${this._deepLinkWarning ? html` + + ` : nothing} +
+ + +
+ `; + } + + render() { + return html` + ${this.renderToolbar()} + ${this.renderContent()} + `; + } +} + +customElements.define('msm-app', MsmApp); + +(async function init() { + const deepLink = parseDeepLink(); + const { context, token } = await DA_SDK; + const cmp = document.createElement('msm-app'); + cmp.context = context; + cmp.token = token; + if (deepLink) cmp.deepLink = deepLink; + document.body.append(cmp); +}()); diff --git a/tools/plugins/msm/README.md b/tools/plugins/msm/README.md new file mode 100644 index 0000000..c54e8b3 --- /dev/null +++ b/tools/plugins/msm/README.md @@ -0,0 +1,126 @@ +# Multi-site Manager (MSM) Plugin + +A DA Prepare-menu plugin for managing multi-site links from the page editor. +Lets authors publish, sync, detach, and reconnect between a source site and the +sites that link to it without leaving DA. + +This plugin is a standalone fork of the OOTB `Multi-site Manager` action that +ships in [`da-live`](https://github.com/adobe/da-live/tree/main/blocks/edit/da-prepare/actions/msm). +The OOTB version is still available by default; sites that want to opt in to +the plugin (e.g. to pin a specific version, or to use the plugin URL from any +site config without copying code) can configure their `prepare` sheet as +described below. + +## How it works + +1. Authors open a page in DA, click the Prepare menu, and pick **Multi-site Manager**. +2. The plugin reads the org's `msm` config sheet to determine the page's role: + - **As a source** — lists the sites that link to this one. + - **As a linked site** — shows the source chain up to the root source. + - **Dual role** — both, shown as separate **Source** and **Linked sites** sections. +3. The author picks an action (publish to preview/live, detach, sync, reconnect, + etc.) and the plugin executes it via the DA Admin and AEM Admin APIs. + +The plugin uses [`DA_SDK`](https://da.live/nx/utils/sdk.js) so all admin calls +are authenticated against the host page's signed-in user — no separate auth +is required. + +## Configuration + +### 1. `.da/config.json` — `msm` sheet + +The plugin reads the org-level `msm` sheet to discover the source/linked +relationships. Columns may use either the original `base`/`satellite` names or +the new `source`/`linked` names — readers accept whichever is present. Each row +describes one site: + +| source | linked | title | +| ------ | ----------- | --------------- | +| mccs | | MCCS Global | +| mccs | san-diego | San Diego | +| mccs | pendleton | Camp Pendleton | + +- A row with `source` set and `linked` empty defines the **source label** for + that site (used in the breadcrumb). +- A row with both `source` and `linked` defines a link edge: the linked site + resolves the source's content unless it holds a detached copy. +- Multi-level links work: a `linked` row can also appear as a `source` row + pointing to deeper linked sites. + +### 2. `.da/config.json` — `prepare` sheet + +To make the plugin appear in the Prepare menu, add a row to the `prepare` +sheet at either the org or site level. Either point at this repo's hosted +plugin (no code copy needed) or use a relative path if you've vendored the +plugin into your own repo: + +**Option A: use the hosted plugin (recommended)** + +| title | path | icon | experience | +| ------------------ | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------- | +| Multi-site Manager | `https://main--da-blog-tools--aemsites.aem.live/tools/plugins/msm/msm.html` | `https://da.live/blocks/edit/img/S2_Icon_GlobeGrid_20_N.svg#S2_Icon_GlobeGrid` | dialog | + +**Option B: vendored copy in your repo** + +| title | path | icon | experience | +| ------------------ | --------------------------------- | --------------------------------------------------------------------- | ---------- | +| Multi-site Manager | `/tools/plugins/msm/msm.html` | `https://da.live/blocks/edit/img/S2_Icon_GlobeGrid_20_N.svg#S2_Icon_GlobeGrid` | dialog | + +> The DA Prepare menu merges items by `title`, so using `Multi-site Manager` +> as the title overrides the OOTB version when this row is present. + +## Behavior matrix + +| Page role | Direction available | Actions | +| ------------- | -------------------- | -------------------------------------------------- | +| Source only | Linked sites (down) | Publish · Preview · Detach · Sync (Merge/Replace) · Reconnect | +| Linked only | Source (up) | Get from source · Sync (Merge/Replace) | +| Both | Source + Linked sites sections | Both sets | + +### Sync modes + +When syncing content from a source into a linked site (or pulling into the +current site from its source), two modes are available: + +- **Merge** — runs a 3-way merge that preserves local edits in the linked site + while pulling in changes from the source. Backed by the + `mergeCopy` function from [`nx/blocks/loc/project`](https://da.live/nx/blocks/loc/project/index.js), + loaded dynamically at runtime. +- **Replace** — replaces the linked site's content with the source's content. + Local edits are lost. + +### Cascade to nested sites + +For recursive actions (Publish / Preview) on sites that have nested +descendants, the publish confirm shows scope chips for the whole subtree, so +the action can run against every linked site below the selected one, not just +the direct child. + +## Relationship to the OOTB version + +This plugin began as a **fork-copy** of `blocks/edit/da-prepare/actions/msm/` in +da-live and has since diverged (rewritten UI/logic over a shared `core/`). The +dependency wiring also differs: + +| Concern | OOTB (da-live) | Plugin (this repo) | +| ------------------ | ----------------------------------------------- | ------------------------------------------------------------------ | +| Lit | `da-lit` resolved internally | `da-lit` resolved via importmap → `/tools/deps/lit/dist/index.js` | +| `daFetch` | `blocks/shared/utils.js` | `DA_SDK.actions.daFetch`, plumbed in via `setSdkFetch` | +| `DA_ORIGIN` | `blocks/shared/constants.js` | `admin.da.live`, defined in the shared `core/fetch.js` | +| NX URL | `getNx()` (versioned/branch-aware) | Hardcoded `https://da.live/nx` | +| `mergeCopy` | Dynamic import via `getNx()` | Dynamic import via the hardcoded NX URL | +| UI primitives | `se-*` components from `${nx}/public/se/components.js` | Own plain ``; + } else if (depth === 0 && d.isDetached === true) { + actionBtn = html``; + } + + // eslint-disable-next-line no-nested-ternary + const toggleClass = !hasKids ? 'leaf' : isCollapsed ? 'closed' : 'open'; + + return html` +
+ + ${this._renderLinkIcon(siteId, parentLabel)} +
+ ${label} + ${hasKids && isCollapsed ? html`${children.length}` : nothing} +
+ ${this._renderStatusIcon(siteId)} + ${actionBtn} + +
+ ${showConfirm ? this.renderConfirmRow() : nothing} + ${hasKids && !isCollapsed ? children.map((child) => this.renderSiteRow(child, depth + 1, label)) : nothing}`; + } + + renderConfirmRow() { + const c = this._pendingConfirm; + if (!c) return nothing; + const isDestructive = ['sync', 'sync-source', 'detach', 'reconnect'].includes(c.type); + + let scopeChips = nothing; + if (c.type === 'publish' && this._fullConfirmScope.length > 0) { + scopeChips = html` +
+ ${this._fullConfirmScope.map((id) => { + const label = this._linkedData.get(id)?.label || id; + const isOn = this._confirmScope.includes(id); + return html` this._toggleScope(id)}> + ${label} + `; + })} + Click to include/exclude +
`; + } + + let actions; + if (c.type === 'publish') { + const targets = [...this._confirmScope]; + const noTargets = targets.length === 0; + actions = html` + + + `; + } else if (c.type === 'sync') { + actions = html` + + + `; + } else if (c.type === 'sync-source') { + actions = html` + + + `; + } else if (c.type === 'detach') { + actions = html` + + `; + } else if (c.type === 'reconnect') { + actions = html` + + `; + } + + const { message, note } = this._confirmText(); + return html` +
+ ${message ? html`
+
${message}
+ ${note ? html`
${note}
` : nothing} +
` : nothing} + ${scopeChips} +
${actions}
+
`; + } + + renderOverflowMenu() { + if (!this._menuSiteId || !this._menuPos) return nothing; + const siteId = this._menuSiteId; + const { top, right } = this._menuPos; + const { org } = this.details; + const manageApp = { label: 'Manage in MSM app ↗', action: () => { window.open(this._getAppDeepLink(), '_blank', 'noopener'); this._closeMenu(); } }; + + let items; + if (siteId === '__source__') { + const { path } = this.details; + const effectiveSite = this._effectiveSource?.site || this._asLinked?.source; + const pageUrl = `https://da.live/edit#/${org}/${effectiveSite}${path}`; + // No Reconnect here: in the source context it would delete the current + // page's own content (the page being edited). That destructive cleanup + // lives in the MSM app, not the in-editor plugin. + items = [ + { label: 'Open source page ↗', action: () => { window.open(pageUrl, '_blank', 'noopener'); this._closeMenu(); } }, + { sep: true }, + manageApp, + ]; + } else { + const d = this._linkedData.get(siteId) || {}; + const pageUrl = `https://da.live/edit#/${org}/${siteId}${this.details.path}`; + const openPage = { label: 'Open page ↗', action: () => { window.open(pageUrl, '_blank', 'noopener'); this._closeMenu(); } }; + + items = d.isDetached === false + ? [ + { + label: 'Detach', + danger: true, + action: () => this._openConfirm(siteId, 'detach'), + }, + { sep: true }, + manageApp, + ] + : [ + { + label: 'Reconnect', + danger: true, + action: () => this._openConfirm(siteId, 'reconnect'), + }, + { sep: true }, + openPage, + manageApp, + ]; + } + + return html` +
+ ${items.map((item) => (item.sep + ? html`
` + : html``))} +
`; + } + + renderSuccessBanner() { + if (!this._successData) return nothing; + const { targets, action, level } = this._successData; + let title = 'Done'; + if (action === 'publish') { + title = `${level === 'live' ? 'Live' : 'Preview'} updated for ${targets.length} site${targets.length === 1 ? '' : 's'}`; + } else if (action === 'detach') { + title = `${this._linkedData.get(targets[0])?.label || targets[0]} detached`; + } else if (action === 'reconnect') { + const label = this._linkedData.get(targets[0])?.label; + title = label ? `${label} reconnected to source` : 'Reconnected to source'; + } else if (action === 'sync-merge') { + title = `${this._linkedData.get(targets[0])?.label || targets[0]} merged from source`; + } else if (action === 'sync-replace') { + title = `${this._linkedData.get(targets[0])?.label || targets[0]} replaced from source`; + } else if (action === 'pull-from-source') { + title = 'Page updated from source'; + } + + const { org, path } = this.details; + const pagePath = path.replace('.html', ''); + + const successLink = (id) => { + if (action === 'reconnect') return nothing; + const label = this._linkedData.get(id)?.label || id; + const url = action === 'publish' + ? `https://main--${id}--${org}.${level === 'live' ? 'aem.live' : 'aem.page'}${pagePath}` + : `https://da.live/edit#/${org}/${id}${path}`; + return html``; + }; + + return html` +
+
${icon('S2_Icon_CheckmarkCircle_20_N', '0 0 18 18')}${title}
+ +
`; + } + + renderSourceSection() { + if (!this._asLinked) return nothing; + const parentLabel = this._asLinked.sourceLabel || this._asLinked.source; + const sourceLabel = this._effectiveSource?.label || parentLabel; + const linkTip = this._isDetached + ? 'Detached (independent copy)' + : `Linked to ${sourceLabel}`; + const linkIcon = this._isDetached === undefined ? nothing + : html` + ${icon(this._isDetached ? 'S2_Icon_UnLink_20_N' : 'S2_Icon_LinkApplied_20_N', '0 0 20 20')} + `; + + let statusIcon; + if (!this._sitePageStatus) { + statusIcon = html``; + } else { + const cfg = getStatusConfig({ + isDetached: this._isDetached, + outOfSync: this._sourceOutOfSync, + previewState: this._sitePageStatus.previewState, + liveState: this._sitePageStatus.liveState, + }); + statusIcon = html`${icon(cfg.name, '0 0 18 18')}`; + } + + const viaNote = this._effectiveSource + ? html`
via ${parentLabel}
` + : nothing; + + const errorNote = this._sourceError + ? html`
${this._sourceError}
` + : nothing; + + return html` +
+
+ +
+
+
+ + ${linkIcon} +
+ ${sourceLabel} +
+ ${statusIcon} +
+ ${this._isDetached + ? html`` + : html``} +
+ +
+ ${this._pendingConfirm?.siteId === '__source__' ? this.renderConfirmRow() : nothing} + ${viaNote} + ${errorNote} +
+
`; + } + + renderLinkedSection() { + if (!this._asSource || !this._tree.length) return nothing; + const hasLinked = this._tree.some((n) => this._linkedData.get(n.siteId)?.isDetached === false); + + return html` +
+
+ + ${hasLinked ? html` + ` : nothing} +
+ ${this._pendingConfirm?.siteId === '__all__' ? this.renderConfirmRow() : nothing} +
+ ${this._tree.map((node) => this.renderSiteRow(node, 0, this._asSource?.sourceLabel))} +
+
`; + } + + render() { + if (this._loading) { + return html`

${this._loading}

`; + } + + if (!this._asSource && !this._asLinked) { + return html`

No linked sites configured.

`; + } + + const { org, site, path } = this.details; + + return html` +
${org}/${site} · ${path}
+
+ ${this._busy + ? html`
Working…
` + : this.renderSuccessBanner()} + ${this.renderSourceSection()} + ${this.renderLinkedSection()} + ${this.renderOverflowMenu()} + `; + } +} + +customElements.define('da-msm', DaMsm); + +export default function render(details) { + const cmp = document.createElement('da-msm'); + cmp.details = details; + return cmp; +} + +(async function initAsDialog() { + if (typeof window === 'undefined' || !document.body) return; + + try { + const { context, actions } = await DA_SDK; + const { org, path, ref } = context; + const site = context.site || context.repo; + console.log('[MSM Plugin] Init context:', { + org, site, path, ref, + }); + + setConfigSdkFetch(actions.daFetch); + setUtilsSdkFetch(actions.daFetch); + + if (document.referrer) { + try { + setEditUrlOrigin(new URL(document.referrer).origin); + } catch { + /* keep default */ + } + } + + const cmp = document.createElement('da-msm'); + cmp.details = { + org, site, path, ref, + }; + document.body.append(cmp); + } catch (error) { + console.error('[MSM Plugin] Initialization error:', error); + const pre = document.createElement('pre'); + pre.style.cssText = 'padding:12px;color:#d31510;font-family:monospace;font-size:12px;'; + pre.textContent = `Failed to initialise MSM plugin: ${error.message}`; + document.body.append(pre); + } +}()); diff --git a/tools/plugins/msm/utils.js b/tools/plugins/msm/utils.js new file mode 100644 index 0000000..2e936b5 --- /dev/null +++ b/tools/plugins/msm/utils.js @@ -0,0 +1,17 @@ +/* eslint-disable import/no-unresolved */ + +// Thin adapter over the shared MSM core. See config.js for the fetch-injection +// rationale. + +export { setDaFetch as setSdkFetch } from '../../apps/msm/core/fetch.js'; +export { + previewPage, + publishPage, + copyFromSource, + deleteCopy, + mergeFromSource, + setEditUrlOrigin, + getEditUrlOrigin, + setMergeCopy, +} from '../../apps/msm/core/operations.js'; +export { getPageStatus, getStatusConfig } from '../../apps/msm/core/status.js';