Skip to content

Commit

Permalink
Made PDPFE.createProjectDataProviderEngine asynchronous, finished hel…
Browse files Browse the repository at this point in the history
…lo world test project type including extension data and project settings (#895)
  • Loading branch information
tjcouch-sil authored May 20, 2024
2 parents b96ee0f + 9b2f4b2 commit b90bf2e
Show file tree
Hide file tree
Showing 13 changed files with 855 additions and 108 deletions.
10 changes: 9 additions & 1 deletion extensions/src/hello-world/contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@
"metadata": {},
"localizedStrings": {
"en": {
"%mainMenu_helloWorldSubmenu%": "Hello World Projects",
"%mainMenu_openHelloWorldProject%": "Open Hello World Project",
"%mainMenu_createNewHelloWorldProject%": "Create New Hello World Project",
"%mainMenu_deleteHelloWorldProject%": "Delete Hello World Project",
"%settings_hello_world_group1_label%": "Hello World Settings",
"%settings_hello_world_personName_label%": "Selected Person's Name on Hello World Web View"
"%settings_hello_world_personName_label%": "Selected Person's Name on Hello World Web View",
"%project_settings_helloWorld_group1_label%": "Hello World Project Settings",
"%project_settings_helloWorld_headerSize_label%": "Header Size",
"%project_settings_helloWorld_headerSize_description%": "Size of the header font in `em`",
"%project_settings_helloWorld_headerColor_label%": "Header Color",
"%project_settings_helloWorld_headerColor_description%": "Color of the headers (must be a valid [HTML color name](https://htmlcolorcodes.com/color-names/))"
}
}
}
35 changes: 31 additions & 4 deletions extensions/src/hello-world/contributions/menus.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,41 @@
{
"mainMenu": {
"columns": {},
"groups": {},
"groups": {
"helloWorld.helloWorldProjects": {
"order": 1.5,
"isExtensible": true,
"menuItem": "helloWorld.projectSubmenu"
}
},
"items": [
{
"label": "%mainMenu_openHelloWorldProject%",
"localizeNotes": "Application main menu > Project > Open Hello World Project",
"id": "helloWorld.projectSubmenu",
"label": "%mainMenu_helloWorldSubmenu%",
"localizeNotes": "Application main menu > Project > Hello World Projects",
"group": "platform.projectProjects",
"order": 1000.1,
"order": 1000.1
},
{
"label": "%mainMenu_openHelloWorldProject%",
"localizeNotes": "Application main menu > Project > Hello World Projects > Open Hello World Project",
"group": "helloWorld.helloWorldProjects",
"order": 1,
"command": "helloWorld.openProject"
},
{
"label": "%mainMenu_createNewHelloWorldProject%",
"localizeNotes": "Application main menu > Project > Hello World Projects > Create New Hello World Project",
"group": "helloWorld.helloWorldProjects",
"order": 2,
"command": "helloWorld.createNewProject"
},
{
"label": "%mainMenu_deleteHelloWorldProject%",
"localizeNotes": "Application main menu > Project > Hello World Projects > Delete Hello World Project",
"group": "helloWorld.helloWorldProjects",
"order": 3,
"command": "helloWorld.deleteProject"
}
]
},
Expand Down
20 changes: 19 additions & 1 deletion extensions/src/hello-world/contributions/projectSettings.json
Original file line number Diff line number Diff line change
@@ -1 +1,19 @@
[]
[
{
"label": "%project_settings_helloWorld_group1_label%",
"properties": {
"helloWorld.headerSize": {
"label": "%project_settings_helloWorld_headerSize_label%",
"description": "%project_settings_helloWorld_headerSize_description%",
"default": 15,
"includeProjectTypes": ["^helloWorld$"]
},
"helloWorld.headerColor": {
"label": "%project_settings_helloWorld_headerColor_label%",
"description": "%project_settings_helloWorld_headerColor_description%",
"default": "Black",
"includeProjectTypes": ["^helloWorld$"]
}
}
}
]
70 changes: 68 additions & 2 deletions extensions/src/hello-world/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ import helloWorldReactWebViewStyles from './web-views/hello-world.web-view.scss?
import helloWorldReactWebView2 from './web-views/hello-world-2.web-view?inline';
import helloWorldReactWebView2Styles from './web-views/hello-world-2.web-view.scss?inline';
import helloWorldHtmlWebView from './web-views/hello-world.web-view.html?inline';
import helloWorldProjectDataProviderEngineFactory from './models/hello-world-project-data-provider-engine-factory.model';
import HelloWorldProjectDataProviderEngineFactory from './models/hello-world-project-data-provider-engine-factory.model';
import helloWorldProjectWebView from './web-views/hello-world-project.web-view?inline';
import helloWorldProjectWebViewStyles from './web-views/hello-world-project.web-view.scss?inline';
import { HTML_COLOR_NAMES } from './util';

/** User data storage key for all hello world project data */
const allProjectDataStorageKey = 'allHelloWorldProjectData';

type IWebViewProviderWithType = IWebViewProvider & { webViewType: string };

Expand Down Expand Up @@ -179,22 +183,80 @@ function helloException(message: string) {
export async function activate(context: ExecutionActivationContext): Promise<void> {
logger.info('Hello world is activating!');

async function readRawDataForAllProjects(): Promise<string> {
try {
return await papi.storage.readUserData(context.executionToken, allProjectDataStorageKey);
} catch {
// No project data found or some other issue. With more important project data, we would be
// more careful not to do something that would overwrite project data if there were an error
return '{}';
}
}

async function writeRawDataForAllProjects(data: string): Promise<void> {
return papi.storage.writeUserData(context.executionToken, allProjectDataStorageKey, data);
}

const helloWorldProjectDataProviderEngineFactory = new HelloWorldProjectDataProviderEngineFactory(
readRawDataForAllProjects,
writeRawDataForAllProjects,
);

const helloWorldPdpefPromise = papi.projectDataProviders.registerProjectDataProviderEngineFactory(
'helloWorld',
helloWorldProjectDataProviderEngineFactory,
helloWorldProjectDataProviderEngineFactory.getAvailableProjects,
);

const openHelloWorldProjectPromise = papi.commands.registerCommand(
'helloWorld.openProject',
openHelloWorldProjectWebView,
);

const createNewHelloWorldProjectPromise = papi.commands.registerCommand(
'helloWorld.createNewProject',
async (openWebView = true) => {
const projectId = await helloWorldProjectDataProviderEngineFactory.createNewProject();

if (openWebView) papi.commands.sendCommand('helloWorld.openProject', projectId);

return projectId;
},
);

const deleteHelloWorldProjectPromise = papi.commands.registerCommand(
'helloWorld.deleteProject',
async (projectId) => {
const projectIdToDelete =
projectId ??
(await papi.dialogs.selectProject({
includeProjectTypes: 'helloWorld',
title: 'Delete Hello World Project',
prompt: 'Please choose a project to delete:',
}));

if (!projectIdToDelete) return false;

// TODO: close web views if this is successful (we don't currently have a way to close them or
// to query for open ones)
return helloWorldProjectDataProviderEngineFactory.deleteProject(projectIdToDelete);
},
);

const helloWorldPersonNamePromise = papi.settings.registerValidator(
'helloWorld.personName',
async (newValue) => typeof newValue === 'string',
);

const helloWorldHeaderSizePromise = papi.projectSettings.registerValidator(
'helloWorld.headerSize',
async (newValue) => typeof newValue === 'number' && Number.isInteger(newValue) && newValue > 0,
);

const helloWorldHeaderColorPromise = papi.projectSettings.registerValidator(
'helloWorld.headerColor',
async (newValue) => HTML_COLOR_NAMES.includes(newValue),
);

const helloWorldProjectWebViewProviderPromise = papi.webViewProviders.register(
helloWorldProjectWebViewProvider.webViewType,
helloWorldProjectWebViewProvider,
Expand Down Expand Up @@ -255,7 +317,11 @@ export async function activate(context: ExecutionActivationContext): Promise<voi
await helloWorldPdpefPromise,
await helloWorldProjectWebViewProviderPromise,
await openHelloWorldProjectPromise,
await createNewHelloWorldProjectPromise,
await deleteHelloWorldProjectPromise,
await helloWorldPersonNamePromise,
await helloWorldHeaderSizePromise,
await helloWorldHeaderColorPromise,
await htmlWebViewProviderPromise,
await reactWebViewProviderPromise,
await reactWebView2ProviderPromise,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,163 @@ import {
IProjectDataProviderEngineFactory,
ProjectMetadata,
} from '@papi/core';
import HelloWorldProjectDataProviderEngine from './hello-world-project-data-provider-engine.model';
import { newGuid } from 'platform-bible-utils';
import HelloWorldProjectDataProviderEngine, {
HelloWorldProjectData,
} from './hello-world-project-data-provider-engine.model';
import { ELIGIBLE_NEW_NAMES } from '../util';

export type AllHelloWorldProjectData = { [projectId: string]: HelloWorldProjectData | undefined };

function createEmptyHelloWorldProjectData(projectName: string): HelloWorldProjectData {
return {
names: new Set(),
numbers: {},
settings: {},
extensionData: {},
projectName,
};
}

class HelloWorldProjectDataProviderEngineFactory
implements IProjectDataProviderEngineFactory<'helloWorld'>
{
/** Do not use directly as it may not have a value. Use `getAllProjectData` */
private allProjectDataCached: AllHelloWorldProjectData | undefined;
private saveAllProjectData: () => Promise<void>;

constructor(
private readRawDataForAllProjects: () => Promise<string>,
writeRawDataForAllProjects: (data: string) => Promise<void>,
) {
this.saveAllProjectData = async () => {
const allProjectData = await this.getAllProjectData();

// Serialize by making the 'names' Set into an array
const allProjectDataRaw = JSON.stringify(allProjectData, (key, value) =>
key === 'names' ? [...value] : value,
);

return writeRawDataForAllProjects(allProjectDataRaw);
};
}

async getAllProjectData(): Promise<AllHelloWorldProjectData> {
if (this.allProjectDataCached) return this.allProjectDataCached;

// We don't have the data, so we need to go get it
const allProjectDataRaw = await this.readRawDataForAllProjects();

// Deserialize by putting the 'names' array back into a Set
const allProjectData: AllHelloWorldProjectData = JSON.parse(allProjectDataRaw, (key, value) =>
key === 'names' ? new Set(value) : value,
);

this.allProjectDataCached = allProjectData;
return allProjectData;
}

async setAndSaveProjectData(projectId: string, projectData: HelloWorldProjectData) {
const allProjectData = await this.getAllProjectData();
allProjectData[projectId] = projectData;
this.allProjectDataCached = allProjectData;
await this.saveAllProjectData();
}

async getAvailableProjects(): Promise<ProjectMetadata[]> {
const allAvailableProjects = Object.entries(await this.getAllProjectData());
return allAvailableProjects.map(([projectId, projectData]) => ({
projectType: 'helloWorld',
id: projectId,
name: projectData?.projectName ?? projectId,
}));
}

async createProjectDataProviderEngine(
projectId: string,
): Promise<IProjectDataProviderEngine<'helloWorld'>> {
const allProjectData = await this.getAllProjectData();
const projectData: HelloWorldProjectData =
allProjectData[projectId] ?? createEmptyHelloWorldProjectData(projectId);
return new HelloWorldProjectDataProviderEngine(projectData, (data) => {
return this.setAndSaveProjectData(projectId, data);
});
}

/**
* Creates a new project with a random name
*
* @returns Project id of the new hello world project
*/
async createNewProject(): Promise<string> {
const allProjectData = await this.getAllProjectData();

let newProjectId = newGuid();
// In production code, please ensure uniqueness better than this
while (allProjectData[newProjectId]) {
newProjectId = newGuid();
}
const newProjectData = createEmptyHelloWorldProjectData(await this.#getUniqueProjectName());

await this.setAndSaveProjectData(newProjectId, newProjectData);

return newProjectId;
}

const helloWorldProjectDataProviderEngineFactory: IProjectDataProviderEngineFactory<'helloWorld'> & {
getAvailableProjects(): Promise<ProjectMetadata[]>;
} = {
/**
* Returns a list of metadata objects for all projects that can be the targets of PDPs created by
* this factory
* Deletes a Hello World project
*
* @param projectId Optional project ID of the project to delete. Prompts the user to select a
* project if not provided
* @returns `true` if successfully deleted
*/
async getAvailableProjects() {
return [];
},
createProjectDataProviderEngine(): IProjectDataProviderEngine<'helloWorld'> {
return new HelloWorldProjectDataProviderEngine();
},
};

export default helloWorldProjectDataProviderEngineFactory;
async deleteProject(projectId: string): Promise<boolean> {
const allProjectData = await this.getAllProjectData();

if (!allProjectData[projectId]) return false;

delete allProjectData[projectId];
this.allProjectDataCached = allProjectData;
await this.saveAllProjectData();
return true;
}

async #getUniqueProjectName(): Promise<string> {
const projectName = ELIGIBLE_NEW_NAMES[Math.floor(ELIGIBLE_NEW_NAMES.length * Math.random())];

const allProjectData = await this.getAllProjectData();

// Look for all projects with the same name and a number after their name, and find the first number missing
const projectNameRegex = new RegExp(`${projectName} ?(?<number>\\d*)`);

const nameNumbers = new Set<number>();
Object.entries(allProjectData).forEach(([, projectData]) => {
if (!projectData?.projectName) return;
const matches = projectNameRegex.exec(projectData.projectName);
if (!matches) return;

if (matches.groups?.number) {
const nameNumber = parseInt(matches.groups.number, 10);
if (typeof nameNumber === 'number') nameNumbers.add(nameNumber);
}
// There was no number, so consider that to be 0
else nameNumbers.add(0);
});

let projectNameCount = 0;

if (nameNumbers.size > 0) {
const nameNumbersArray = [...nameNumbers];
nameNumbersArray.sort((a, b) => (a > b ? 1 : -1));
const nameNumberMissingIndex = nameNumbersArray.findIndex(
(nameNumber, i) => nameNumber !== i,
);
projectNameCount =
nameNumberMissingIndex === -1 ? nameNumbersArray.length : nameNumberMissingIndex;
}

return `${projectName}${projectNameCount !== 0 ? ` ${projectNameCount}` : ''}`;
}
}

export default HelloWorldProjectDataProviderEngineFactory;
Loading

0 comments on commit b90bf2e

Please sign in to comment.