Files
static-cms/packages/core/src/backends/test/implementation.ts
2024-04-26 10:30:48 -04:00

504 lines
13 KiB
TypeScript

import attempt from 'lodash/attempt';
import isError from 'lodash/isError';
import take from 'lodash/take';
import trim from 'lodash/trim';
import unset from 'lodash/unset';
import { basename, dirname } from 'path';
import { v4 as uuid } from 'uuid';
import {
Cursor,
CURSOR_COMPATIBILITY_SYMBOL,
EditorialWorkflowError,
} from '@staticcms/core/lib/util';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import AuthenticationPage from './AuthenticationPage';
import type {
BackendClass,
BackendEntry,
ConfigWithDefaults,
DataFile,
DisplayURL,
ImplementationEntry,
ImplementationFile,
ImplementationMediaFile,
PersistOptions,
UnpublishedEntry,
User,
} from '@staticcms/core';
import type { WorkflowStatus } from '@staticcms/core/constants/publishModes';
type RepoFile = { path: string; content: string | AssetProxy; isDirectory?: boolean };
type RepoTree = { [key: string]: RepoFile | RepoTree };
type Diff = {
id: string;
originalPath?: string;
path: string;
newFile: boolean;
status: string;
content: string | AssetProxy;
};
type UnpublishedRepoEntry = {
slug: string;
collection: string;
status: WorkflowStatus;
diffs: Diff[];
updatedAt: string;
};
declare global {
interface Window {
repoFiles: RepoTree;
repoFilesUnpublished: { [key: string]: UnpublishedRepoEntry };
}
}
window.repoFiles = window.repoFiles || {};
window.repoFilesUnpublished = window.repoFilesUnpublished || [];
function getFile(path: string, tree: RepoTree): RepoFile | undefined {
const segments = path.split('/');
let obj: RepoTree = tree;
while (obj && segments.length) {
obj = obj[segments.shift() as string] as RepoTree;
}
return (obj as unknown as RepoFile) || undefined;
}
function writeFile(path: string, content: string | AssetProxy, tree: RepoTree) {
const segments = path.split('/');
let obj = tree;
while (segments.length > 1) {
const segment = segments.shift() as string;
obj[segment] = obj[segment] || {};
obj = obj[segment] as RepoTree;
}
(obj[segments.shift() as string] as RepoFile) = { content, path };
}
function deleteFile(path: string, tree: RepoTree) {
unset(tree, path.split('/'));
}
const pageSize = 10;
function getCursor(
folder: string,
extension: string,
entries: ImplementationEntry[],
index: number,
depth: number,
) {
const count = entries.length;
const pageCount = Math.floor(count / pageSize);
return Cursor.create({
actions: [
...(index < pageCount ? ['next', 'last'] : []),
...(index > 0 ? ['prev', 'first'] : []),
],
meta: { index, count, pageSize, pageCount },
data: { folder, extension, index, pageCount, depth },
});
}
export function getFolderFiles(
tree: RepoTree,
folder: string,
extension: string,
depth: number,
files = [] as RepoFile[],
path = folder,
includeFolders?: boolean,
): RepoFile[] {
if (depth <= 0) {
return files;
}
if (includeFolders) {
files.unshift({ isDirectory: true, content: '', path });
}
Object.keys(tree[folder] || {}).forEach(key => {
const parts = key.split('.');
const keyExtension = parts.length > 1 ? parts[parts.length - 1] : '';
if (isNotEmpty(keyExtension)) {
const file = (tree[folder] as RepoTree)[key] as RepoFile;
if (!extension || key.endsWith(`.${extension}`)) {
files.unshift({ content: file.content, path: `${path}/${key}` });
}
} else {
const subTree = tree[folder] as RepoTree;
return getFolderFiles(
subTree,
key,
extension,
depth - 1,
files,
`${path}/${key}`,
includeFolders,
);
}
});
return files;
}
export default class TestBackend implements BackendClass {
mediaFolder?: string;
options: { initialWorkflowStatus?: string };
constructor(config: ConfigWithDefaults, options = {}) {
this.options = options;
this.mediaFolder = config.media_folder;
}
status() {
return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } });
}
authComponent() {
return AuthenticationPage;
}
restoreUser() {
return this.authenticate();
}
authenticate() {
return Promise.resolve() as unknown as Promise<User>;
}
logout() {
return null;
}
getToken() {
return Promise.resolve('');
}
traverseCursor(cursor: Cursor, action: string) {
const { folder, extension, index, pageCount, depth } = cursor.data as {
folder: string;
extension: string;
index: number;
pageCount: number;
depth: number;
};
const newIndex = (() => {
if (action === 'next') {
return (index as number) + 1;
}
if (action === 'prev') {
return (index as number) - 1;
}
if (action === 'first') {
return 0;
}
if (action === 'last') {
return pageCount;
}
return 0;
})();
// TODO: stop assuming cursors are for collections
const allFiles = getFolderFiles(window.repoFiles, folder, extension, depth);
const allEntries = allFiles.map(f => ({
data: f.content as string,
file: { path: f.path, id: f.path },
}));
const entries = allEntries.slice(newIndex * pageSize, newIndex * pageSize + pageSize);
const newCursor = getCursor(folder, extension, allEntries, newIndex, depth);
return Promise.resolve({ entries, cursor: newCursor });
}
entriesByFolder(folder: string, extension: string, depth: number) {
const files = folder ? getFolderFiles(window.repoFiles, folder, extension, depth) : [];
const entries = files.map(f => ({
data: f.content as string,
file: { path: f.path, id: f.path },
}));
const cursor = getCursor(folder, extension, entries, 0, depth);
const ret = take(entries, pageSize);
// TODO Remove
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ret[CURSOR_COMPATIBILITY_SYMBOL] = cursor;
return Promise.resolve(ret);
}
entriesByFiles(files: ImplementationFile[]) {
return Promise.all(
files.map(file => ({
file,
data: (getFile(file.path, window.repoFiles)?.content ?? '') as string,
})),
);
}
getEntry(path: string) {
return Promise.resolve({
file: { path, id: null },
data: (getFile(path, window.repoFiles)?.content ?? '') as string,
});
}
async persistEntry(entry: BackendEntry, options: PersistOptions) {
if (options.useWorkflow) {
const slug = entry.dataFiles[0].slug;
const key = `${options.collectionName}/${slug}`;
const currentEntry = window.repoFilesUnpublished[key];
const status = currentEntry?.status || options.status || this.options.initialWorkflowStatus;
this.addOrUpdateUnpublishedEntry(
key,
entry.dataFiles,
entry.assets,
slug,
options.collectionName as string,
status,
);
return Promise.resolve();
}
entry.dataFiles.forEach(dataFile => {
const { path, newPath, raw } = dataFile;
if (newPath) {
deleteFile(path, window.repoFiles);
writeFile(newPath, raw, window.repoFiles);
} else {
writeFile(path, raw, window.repoFiles);
}
});
entry.assets.forEach(a => {
writeFile(a.path, a, window.repoFiles);
});
return Promise.resolve();
}
async getMedia(
mediaFolder = this.mediaFolder,
folderSupport?: boolean,
): Promise<ImplementationMediaFile[]> {
if (!mediaFolder) {
return [];
}
const files = getFolderFiles(
window.repoFiles,
trim(mediaFolder, '/').split('/')[0],
'',
100,
undefined,
undefined,
folderSupport,
).filter(f => {
return dirname(f.path) === trim(mediaFolder, '/');
});
return files.map(f => ({
name: basename(f.path),
id: f.path,
path: f.path,
displayURL: f.path,
isDirectory: f.isDirectory ?? false,
}));
}
async getMediaFile(path: string) {
return {
id: path,
displayURL: path,
path,
name: basename(path),
size: 1,
url: path,
};
}
normalizeAsset(assetProxy: AssetProxy): ImplementationMediaFile {
const fileObj = assetProxy.fileObj as File;
const { name, size } = fileObj;
const objectUrl = attempt(window.URL.createObjectURL, fileObj);
const url = isError(objectUrl) ? '' : objectUrl;
const normalizedAsset = {
id: uuid(),
name,
size,
path: assetProxy.path,
url,
displayURL: url,
file: fileObj,
};
return normalizedAsset;
}
persistMedia(assetProxy: AssetProxy) {
const normalizedAsset = this.normalizeAsset(assetProxy);
writeFile(assetProxy.path, assetProxy, window.repoFiles);
return Promise.resolve(normalizedAsset);
}
deleteFiles(paths: string[]) {
paths.forEach(path => {
deleteFile(path, window.repoFiles);
});
return Promise.resolve();
}
async allEntriesByFolder(
folder: string,
extension: string,
depth: number,
): Promise<ImplementationEntry[]> {
const files = folder ? getFolderFiles(window.repoFiles, folder, extension, depth) : [];
const entries = files.map(f => ({
data: f.content as string,
file: { path: f.path, id: f.path },
}));
return Promise.resolve(entries);
}
getMediaDisplayURL(_displayURL: DisplayURL): Promise<string> {
throw new Error('Not supported');
}
/**
* Editorial Workflow
*/
unpublishedEntries() {
return Promise.resolve(Object.keys(window.repoFilesUnpublished));
}
unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}): Promise<UnpublishedEntry> {
if (id) {
const parts = id.split('/');
collection = parts[0];
slug = parts[1];
}
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
if (!entry) {
return Promise.reject(
new EditorialWorkflowError('content is not under editorial workflow', true),
);
}
return Promise.resolve({
...entry,
openAuthoring: false,
});
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string) {
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
const file = entry.diffs.find(d => d.path === path);
return file?.content as string;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string) {
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
const file = entry.diffs.find(d => d.path === path);
return this.normalizeAsset(file?.content as AssetProxy);
}
deleteUnpublishedEntry(collection: string, slug: string) {
delete window.repoFilesUnpublished[`${collection}/${slug}`];
return Promise.resolve();
}
async addOrUpdateUnpublishedEntry(
key: string,
dataFiles: DataFile[],
assetProxies: AssetProxy[],
slug: string,
collection: string,
status: WorkflowStatus,
) {
const diffs: Diff[] = [];
dataFiles.forEach(dataFile => {
const { path, newPath, raw } = dataFile;
const currentDataFile = window.repoFilesUnpublished[key]?.diffs.find(d => d.path === path);
const originalPath = currentDataFile ? currentDataFile.originalPath : path;
diffs.push({
originalPath,
id: newPath || path,
path: newPath || path,
newFile: isNullish(getFile(originalPath as string, window.repoFiles)),
status: 'added',
content: raw,
});
});
assetProxies.forEach(a => {
const asset = this.normalizeAsset(a);
diffs.push({
id: asset.id,
path: asset.path,
newFile: true,
status: 'added',
content: new AssetProxy(asset),
});
});
window.repoFilesUnpublished[key] = {
slug,
collection,
status,
diffs,
updatedAt: new Date().toISOString(),
};
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) {
window.repoFilesUnpublished[`${collection}/${slug}`].status = newStatus;
return Promise.resolve();
}
publishUnpublishedEntry(collection: string, slug: string) {
const key = `${collection}/${slug}`;
const unpubEntry = window.repoFilesUnpublished[key];
delete window.repoFilesUnpublished[key];
const tree = window.repoFiles;
unpubEntry.diffs.forEach(d => {
if (d.originalPath && !d.newFile) {
const originalPath = d.originalPath;
const sourceDir = dirname(originalPath);
const destDir = dirname(d.path);
const toMove = getFolderFiles(tree, originalPath.split('/')[0], '', 100).filter(f =>
f.path.startsWith(sourceDir),
);
toMove.forEach(f => {
deleteFile(f.path, tree);
writeFile(f.path.replace(sourceDir, destDir), f.content, tree);
});
}
writeFile(d.path, d.content, tree);
});
return Promise.resolve();
}
async getDeployPreview() {
return null;
}
}