-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2253 from navikt/archive-navigation-tree
Henter innhold fra XP-arkivet i tjenester for eksternt arkiv
- Loading branch information
Showing
14 changed files
with
600 additions
and
249 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
// WARNING: This file was automatically generated by "no.item.xp.codegen". You may lose your changes if you edit it. | ||
export type RefreshArchiveContentTrees = { | ||
|
||
}; |
298 changes: 264 additions & 34 deletions
298
src/main/resources/lib/external-archive/content-tree-archive.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,48 +1,278 @@ | ||
import { RepoConnection } from '/lib/xp/node'; | ||
import { getParentPath } from '../paths/path-utils'; | ||
import { RepoConnection, RepoNode } from '/lib/xp/node'; | ||
import { stripLeadingAndTrailingSlash, stripPathPrefix } from '../paths/path-utils'; | ||
import { logger } from '../utils/logging'; | ||
import { Content } from '/lib/xp/content'; | ||
import { getRepoConnection } from '../utils/repo-utils'; | ||
import { getLayersData } from '../localization/layers-data'; | ||
import { NON_LOCALIZED_QUERY_FILTER } from '../localization/layers-repo-utils/localization-state-filters'; | ||
import { ContentTreeEntry, transformToContentTreeEntry } from './content-tree-entry'; | ||
import { generateUUID } from '../utils/uuid'; | ||
import { createOrUpdateSchedule } from '../scheduling/schedule-job'; | ||
import { APP_DESCRIPTOR } from '../constants'; | ||
|
||
export const getContentFromArchive = (path: string, repo: RepoConnection) => { | ||
const parentPath = getParentPath(path); | ||
const name = path.split('/').pop(); | ||
export type ArchiveTreeNode = { | ||
path: string; | ||
name: string; | ||
content: ContentTreeEntry; | ||
children: Record<string, ArchiveTreeNode>; | ||
}; | ||
|
||
const archivedResult = repo.query({ | ||
count: 1, | ||
sort: { | ||
field: '_ts', | ||
direction: 'DESC', | ||
}, | ||
filters: { | ||
boolean: { | ||
must: [ | ||
{ | ||
hasValue: { | ||
field: 'originalParentPath', | ||
values: [parentPath], | ||
// Builds a tree structure for archived content, matching the original structure of the content. | ||
// Used for navigating the archive in the external archive frontend. | ||
class ArchiveContentTree { | ||
private readonly BATCH_COUNT = 1000; | ||
|
||
private readonly locale: string; | ||
private readonly repoConnection: RepoConnection; | ||
private readonly rootNode: ArchiveTreeNode; | ||
private readonly pathMap: Record<string, ArchiveTreeNode> = {}; | ||
|
||
constructor(locale: string) { | ||
this.locale = locale; | ||
this.repoConnection = getRepoConnection({ | ||
branch: 'draft', | ||
asAdmin: true, | ||
repoId: getLayersData().localeToRepoIdMap[locale], | ||
}); | ||
|
||
const rootNodeName = `archive-root-node-${locale}`; | ||
this.rootNode = { | ||
path: '/', | ||
name: rootNodeName, | ||
content: this.buildEmptyContentTreeEntry('/', rootNodeName), | ||
children: {}, | ||
}; | ||
this.pathMap['/'] = this.rootNode; | ||
} | ||
|
||
public build() { | ||
const start = Date.now(); | ||
this.processContentBatch(); | ||
this.updateChildrenCount(); | ||
const durationSec = (Date.now() - start) / 1000; | ||
|
||
const numEntries = Object.values(this.pathMap).length; | ||
|
||
logger.info( | ||
`Finished building archive content tree with ${numEntries} entries for "${this.locale}" in ${durationSec} seconds` | ||
); | ||
|
||
return this; | ||
} | ||
|
||
public getContentTreeEntry(path: string): ArchiveTreeNode | null { | ||
return this.pathMap[path] ?? null; | ||
} | ||
|
||
public getContentNodeList() { | ||
return ( | ||
Object.values(this.pathMap) | ||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
.map(({ children, ...entry }) => entry) | ||
.sort((a, b) => (a.path > b.path ? 1 : -1)) | ||
); | ||
} | ||
|
||
public getContentTree() { | ||
return this.rootNode; | ||
} | ||
|
||
private processContentBatch(start: number = 0) { | ||
const parentContentBatch = this.repoConnection.query({ | ||
start, | ||
count: this.BATCH_COUNT, | ||
filters: { | ||
boolean: { | ||
mustNot: NON_LOCALIZED_QUERY_FILTER, | ||
must: [ | ||
{ | ||
exists: { | ||
field: 'originalParentPath', | ||
}, | ||
}, | ||
}, | ||
{ | ||
hasValue: { | ||
field: 'originalName', | ||
values: [name], | ||
{ | ||
exists: { | ||
field: 'originalName', | ||
}, | ||
}, | ||
}, | ||
], | ||
{ | ||
exists: { | ||
field: 'publish.first', | ||
}, | ||
}, | ||
], | ||
}, | ||
}, | ||
}, | ||
}); | ||
query: { | ||
boolean: { | ||
must: [ | ||
{ | ||
like: { | ||
field: '_path', | ||
value: '/archive/*', | ||
}, | ||
}, | ||
], | ||
}, | ||
}, | ||
}); | ||
|
||
parentContentBatch.hits.forEach((hit) => { | ||
const content = this.repoConnection.get<Content>(hit.id); | ||
if (!content?.originalParentPath) { | ||
logger.error( | ||
`No valid content found for ${hit.id} [${this.locale}] (found ${content?._id})` | ||
); | ||
return; | ||
} | ||
|
||
const originalPath = `${content.originalParentPath}/${content.originalName}`; | ||
this.addToContentTree(content, stripPathPrefix(originalPath)); | ||
}); | ||
|
||
const nextStart = start + this.BATCH_COUNT; | ||
|
||
if (parentContentBatch.total > nextStart) { | ||
this.processContentBatch(nextStart); | ||
} | ||
} | ||
|
||
private addToContentTree(content: RepoNode<Content>, path: string) { | ||
const pathSegments = stripLeadingAndTrailingSlash(path).split('/'); | ||
const treeNode = this.getOrCreateTreeNode(pathSegments, 0); | ||
|
||
// If there are multiple archived contents which had the same "live" path | ||
// we need to create a new unique path for this content | ||
if (!treeNode.content.isEmpty) { | ||
this.addToContentTree(content, `${path}/${content._id}`); | ||
return; | ||
} | ||
|
||
treeNode.content = this.transformToContentTreeEntry(content, path); | ||
|
||
if (archivedResult.total === 0) { | ||
return null; | ||
this.processChildren(treeNode, content); | ||
} | ||
|
||
if (archivedResult.total > 1) { | ||
logger.error(`Found ${archivedResult.total} hits in the archive for ${path}`); | ||
private transformToContentTreeEntry(content: RepoNode<Content>, path: string) { | ||
// Use the "virtual" path in our content tree structure instead of the XP archive path | ||
const contentWithVirtualPath = { | ||
...content, | ||
_path: path, | ||
}; | ||
|
||
return transformToContentTreeEntry( | ||
contentWithVirtualPath, | ||
this.repoConnection, | ||
this.locale | ||
); | ||
} | ||
|
||
const content = repo.get<Content>(archivedResult.hits[0].id); | ||
if (!content) { | ||
return null; | ||
// Traverses the tree until it hits the target path, and returns the node for that path | ||
// Creates any missing nodes along the way. | ||
private getOrCreateTreeNode( | ||
pathSegments: string[], | ||
currentSegmentIndex: number, | ||
parentNode = this.rootNode | ||
): ArchiveTreeNode { | ||
const currentSegment = pathSegments[currentSegmentIndex]; | ||
|
||
if (!parentNode.children[currentSegment]) { | ||
const currentPath = `/${pathSegments.slice(0, currentSegmentIndex + 1).join('/')}`; | ||
const newNode = { | ||
path: currentPath, | ||
name: currentSegment, | ||
children: {}, | ||
content: this.buildEmptyContentTreeEntry(currentPath, currentSegment), | ||
}; | ||
parentNode.children[currentSegment] = newNode; | ||
this.pathMap[currentPath] = newNode; | ||
} | ||
|
||
const currentNode = parentNode.children[currentSegment]; | ||
|
||
const nextSegmentIndex = currentSegmentIndex + 1; | ||
|
||
return nextSegmentIndex === pathSegments.length | ||
? currentNode | ||
: this.getOrCreateTreeNode(pathSegments, nextSegmentIndex, currentNode); | ||
} | ||
|
||
private processChildren( | ||
parentTreeNode: ArchiveTreeNode, | ||
parentContentNode: RepoNode<Content>, | ||
start = 0 | ||
) { | ||
const children = this.repoConnection.findChildren({ | ||
parentKey: parentContentNode._id, | ||
start, | ||
count: this.BATCH_COUNT, | ||
}); | ||
|
||
children.hits.forEach(({ id }) => { | ||
const childContent = this.repoConnection.get<Content>(id); | ||
if (!childContent) { | ||
logger.error( | ||
`No child content found for ${id} [${this.locale}] (parent: ${parentContentNode._id})` | ||
); | ||
return; | ||
} | ||
|
||
const path = `${parentTreeNode.path}/${childContent._name}`; | ||
|
||
this.addToContentTree(childContent, path); | ||
}); | ||
|
||
const nextStart = start + this.BATCH_COUNT; | ||
|
||
if (children.total > nextStart) { | ||
this.processChildren(parentTreeNode, parentContentNode, nextStart); | ||
} | ||
} | ||
|
||
private buildEmptyContentTreeEntry(path: string, name: string): ContentTreeEntry { | ||
return { | ||
id: `empty-node-${generateUUID()}`, | ||
path, | ||
name, | ||
displayName: name, | ||
type: 'base:folder', | ||
locale: this.locale, | ||
numChildren: 0, | ||
isLocalized: true, | ||
hasLocalizedDescendants: true, | ||
isEmpty: true, | ||
}; | ||
} | ||
|
||
private updateChildrenCount() { | ||
Object.values(this.pathMap).forEach((entry) => { | ||
entry.content.numChildren = Object.keys(entry.children).length; | ||
}); | ||
} | ||
} | ||
|
||
export const ArchiveContentTrees: Record<string, ArchiveContentTree> = {}; | ||
|
||
export const refreshArchiveContentTrees = () => { | ||
const { locales } = getLayersData(); | ||
logger.info(`Building archive content trees for locales: ${locales}`); | ||
locales.forEach((locale) => { | ||
ArchiveContentTrees[locale] = new ArchiveContentTree(locale).build(); | ||
}); | ||
}; | ||
|
||
// This performs a full refresh once per day. | ||
// TODO: Ideally it should update on archive events. | ||
export const initArchiveContentTrees = () => { | ||
refreshArchiveContentTrees(); | ||
createOrUpdateSchedule({ | ||
jobName: 'refresh-archive-content-trees', | ||
jobSchedule: { | ||
type: 'CRON', | ||
value: '0 7 * * *', | ||
timeZone: 'GMT+2:00', | ||
}, | ||
taskDescriptor: `${APP_DESCRIPTOR}:refresh-archive-content-trees`, | ||
taskConfig: {}, | ||
}); | ||
}; |
61 changes: 61 additions & 0 deletions
61
src/main/resources/lib/external-archive/content-tree-entry.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import { ContentDescriptor } from '../../types/content-types/content-config'; | ||
import { Content } from '/lib/xp/content'; | ||
import { RepoConnection } from '/lib/xp/node'; | ||
import { stripPathPrefix } from '../paths/path-utils'; | ||
import { isContentLocalized } from '../localization/locale-utils'; | ||
import { NON_LOCALIZED_QUERY_FILTER } from '../localization/layers-repo-utils/localization-state-filters'; | ||
|
||
export type ContentTreeEntry = { | ||
id: string; | ||
path: string; | ||
name: string; | ||
displayName: string; | ||
type: ContentDescriptor; | ||
locale: string; | ||
numChildren: number; | ||
isLocalized: boolean; | ||
hasLocalizedDescendants: boolean; | ||
isEmpty?: boolean; | ||
}; | ||
|
||
const hasLocalizedDescendants = (content: Content, repo: RepoConnection) => { | ||
const result = repo.query({ | ||
count: 0, | ||
query: { | ||
like: { | ||
field: '_path', | ||
value: `${content._path}/*`, | ||
}, | ||
}, | ||
filters: { | ||
boolean: { | ||
mustNot: NON_LOCALIZED_QUERY_FILTER, | ||
}, | ||
}, | ||
}); | ||
|
||
return result.total > 0; | ||
}; | ||
|
||
export const transformToContentTreeEntry = ( | ||
content: Content, | ||
repo: RepoConnection, | ||
locale: string | ||
): ContentTreeEntry => { | ||
const childrenResult = repo.findChildren({ | ||
parentKey: content._id, | ||
countOnly: true, | ||
}); | ||
|
||
return { | ||
id: content._id, | ||
path: stripPathPrefix(content._path), | ||
name: content._name, | ||
displayName: content.displayName, | ||
type: content.type, | ||
locale, | ||
numChildren: childrenResult.total, | ||
isLocalized: isContentLocalized(content), | ||
hasLocalizedDescendants: hasLocalizedDescendants(content, repo), | ||
}; | ||
}; |
Oops, something went wrong.