diff --git a/webapp/.gitignore b/webapp/.gitignore index 7507a42..3b34769 100644 --- a/webapp/.gitignore +++ b/webapp/.gitignore @@ -31,3 +31,6 @@ npm-debug.log* # typescript *.tsbuildinfo next-env.d.ts + +# Lyra +store.json diff --git a/webapp/src/Cache.ts b/webapp/src/Cache.ts deleted file mode 100644 index 9c915ee..0000000 --- a/webapp/src/Cache.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* global globalThis */ - -import { LanguageNotSupported } from '@/errors'; -import { LyraProjectConfig } from '@/utils/lyraConfig'; -import { ProjectStore } from '@/store/ProjectStore'; -import { RepoGit } from '@/RepoGit'; -import { ServerConfig } from '@/utils/serverConfig'; -import { Store } from '@/store/Store'; -import YamlTranslationAdapter from '@/utils/adapters/YamlTranslationAdapter'; -import MessageAdapterFactory from './utils/adapters/MessageAdapterFactory'; - -export class Cache { - public static async getLanguage(projectName: string, lang: string) { - const serverProjectConfig = - await ServerConfig.getProjectConfig(projectName); - const repoGit = await RepoGit.getRepoGit(serverProjectConfig); - const lyraConfig = await repoGit.getLyraConfig(); - const lyraProjectConfig = lyraConfig.getProjectConfigByPath( - serverProjectConfig.projectPath, - ); - if (!lyraProjectConfig.isLanguageSupported(lang)) { - throw new LanguageNotSupported(lang, projectName); - } - const store = await Cache.getProjectStore(lyraProjectConfig); - return store.getTranslations(lang); - } - - public static async getProjectStore( - lyraProjectConfig: LyraProjectConfig, - ): Promise { - if (!globalThis.store) { - globalThis.store = new Store(); - } - - if (!globalThis.store.hasProjectStore(lyraProjectConfig.absPath)) { - const projectStore = new ProjectStore( - MessageAdapterFactory.createAdapter(lyraProjectConfig), - new YamlTranslationAdapter(lyraProjectConfig.absTranslationsPath), - ); - globalThis.store.addProjectStore(lyraProjectConfig.absPath, projectStore); - } - - return globalThis.store.getProjectStore(lyraProjectConfig.absPath); - } -} diff --git a/webapp/src/RepoGit.ts b/webapp/src/RepoGit.ts index 2231f60..ffd04b1 100644 --- a/webapp/src/RepoGit.ts +++ b/webapp/src/RepoGit.ts @@ -4,7 +4,6 @@ import { Octokit } from '@octokit/rest'; import path from 'path'; import { stringify } from 'yaml'; -import { Cache } from '@/Cache'; import { IGit } from '@/utils/git/IGit'; import { LyraConfig } from '@/utils/lyraConfig'; import packageJson from '../package.json'; @@ -15,6 +14,7 @@ import { debug, info, warn } from '@/utils/log'; import { WriteLanguageFileError, WriteLanguageFileErrors } from '@/errors'; import { type TranslationMap } from '@/utils/adapters'; import { getTranslationsBySourceFile } from '@/utils/translationObjectUtil'; +import { Store } from '@/store/Store'; export class RepoGit { private static repositories: { @@ -83,7 +83,7 @@ export class RepoGit { public async saveLanguageFiles(projectPath: string): Promise { const lyraConfig = await this.getLyraConfig(); const projectConfig = lyraConfig.getProjectConfigByPath(projectPath); - const projectStore = await Cache.getProjectStore(projectConfig); + const projectStore = await Store.getProjectStore(projectConfig); const languages = await projectStore.getLanguageData(); return await this.writeLangFiles( diff --git a/webapp/src/actions/updateTranslation.ts b/webapp/src/actions/updateTranslation.ts index 58b1bdf..879ab5d 100644 --- a/webapp/src/actions/updateTranslation.ts +++ b/webapp/src/actions/updateTranslation.ts @@ -1,7 +1,7 @@ 'use server'; -import { Cache } from '@/Cache'; import { RepoGit } from '@/RepoGit'; +import { Store } from '@/store/Store'; import { ServerConfig } from '@/utils/serverConfig'; import { MessageNotFound } from '@/errors'; @@ -78,7 +78,7 @@ export default async function updateTranslation( }; } - const projectStore = await Cache.getProjectStore(projectConfig); + const projectStore = await Store.getProjectStore(projectConfig); const messages = await projectStore.getMessages(); const messageIds = messages.map((message) => message.id); @@ -90,6 +90,7 @@ export default async function updateTranslation( try { await projectStore.updateTranslation(languageName, messageId, translation); + await Store.persistToDisk(); } catch (e) { return { errorMessage: 'Failed to update translation', diff --git a/webapp/src/dataAccess.ts b/webapp/src/dataAccess.ts index 8858f71..0518e01 100644 --- a/webapp/src/dataAccess.ts +++ b/webapp/src/dataAccess.ts @@ -1,7 +1,8 @@ -import { Cache } from '@/Cache'; import { RepoGit } from '@/RepoGit'; import { ServerConfig, ServerProjectConfig } from '@/utils/serverConfig'; import { getTranslationsIdText } from './utils/translationObjectUtil'; +import { LanguageNotSupported } from './errors'; +import { Store } from '@/store/Store'; export async function accessProjects() { const serverConfig = await ServerConfig.read(); @@ -41,12 +42,16 @@ export async function accessLanguage( await repoGit.checkoutBaseAndPull(); const lyraConfig = await repoGit.getLyraConfig(); const projectConfig = lyraConfig.getProjectConfigByPath(project.projectPath); - const projectStore = await Cache.getProjectStore(projectConfig); + const projectStore = await Store.getProjectStore(projectConfig); const messages = await projectStore.getMessages(); - const translationsWithFilePath = await Cache.getLanguage( - projectName, - languageName, - ); + + if (!projectConfig.isLanguageSupported(languageName)) { + throw new LanguageNotSupported(languageName, projectName); + } + + const translationsWithFilePath = + await projectStore.getTranslations(languageName); + const translations = getTranslationsIdText(translationsWithFilePath); return { @@ -61,7 +66,7 @@ async function readProject(project: ServerProjectConfig) { await repoGit.checkoutBaseAndPull(); const lyraConfig = await repoGit.getLyraConfig(); const projectConfig = lyraConfig.getProjectConfigByPath(project.projectPath); - const store = await Cache.getProjectStore(projectConfig); + const store = await Store.getProjectStore(projectConfig); const messages = await store.getMessages(); const languagesWithTranslations = projectConfig.languages.map( async (lang) => { diff --git a/webapp/src/errors.ts b/webapp/src/errors.ts index 0f5fead..b775022 100644 --- a/webapp/src/errors.ts +++ b/webapp/src/errors.ts @@ -1,9 +1,3 @@ -export class LanguageNotFound extends Error { - constructor(lang: string) { - super(`Language ${lang} not found`); - } -} - export class LanguageNotSupported extends Error { constructor(lang: string, projectName: string) { super(`Language ${lang} is not supported in project ${projectName}`); diff --git a/webapp/src/store/ProjectStore.spec.ts b/webapp/src/store/ProjectStore.spec.ts index 06cbcb0..b323d3d 100644 --- a/webapp/src/store/ProjectStore.spec.ts +++ b/webapp/src/store/ProjectStore.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, it, jest } from '@jest/globals'; import { ProjectStore } from './ProjectStore'; -import { LanguageNotFound } from '@/errors'; import { IMessageAdapter } from '@/utils/adapters'; function mockMsgAdapter(): jest.Mocked { @@ -58,7 +57,7 @@ describe('ProjectStore', () => { }); }); - it('throws exception for missing language', async () => { + it('returns empty object for missing language', async () => { expect.assertions(1); const msgAdapter = mockMsgAdapter(); msgAdapter.getMessages.mockResolvedValue([]); @@ -66,8 +65,8 @@ describe('ProjectStore', () => { getTranslations: async () => ({}), }); - const actual = projectStore.getTranslations('fi'); - await expect(actual).rejects.toThrowError(LanguageNotFound); + const actual = await projectStore.getTranslations('fi'); + expect(actual).toEqual({}); }); it('returns updated translations', async () => { @@ -141,29 +140,6 @@ describe('ProjectStore', () => { }); }); - it('throws exception for missing language', async () => { - expect.assertions(1); - const msgAdapter = mockMsgAdapter(); - msgAdapter.getMessages.mockResolvedValue([ - { - defaultMessage: '', - id: 'greeting.headline', - params: [], - }, - ]); - const projectStore = new ProjectStore(msgAdapter, { - getTranslations: async () => ({}), - }); - - const actual = projectStore.updateTranslation( - 'de', - 'greeting.headline', - 'Hallo!', - ); - - await expect(actual).rejects.toThrowError(LanguageNotFound); - }); - it('gives full access to all languages', async () => { const msgAdapter = mockMsgAdapter(); msgAdapter.getMessages.mockResolvedValue([ diff --git a/webapp/src/store/ProjectStore.ts b/webapp/src/store/ProjectStore.ts index 3fcae17..a7477b6 100644 --- a/webapp/src/store/ProjectStore.ts +++ b/webapp/src/store/ProjectStore.ts @@ -5,7 +5,6 @@ import { MessageMap, TranslationMap, } from '@/utils/adapters'; -import { LanguageNotFound } from '@/errors'; import { StoreData } from './types'; import mergeStoreData from './mergeStoreData'; @@ -17,8 +16,9 @@ export class ProjectStore { constructor( messageAdapter: IMessageAdapter, translationAdapter: ITranslationAdapter, + initialState?: StoreData, ) { - this.data = { + this.data = initialState || { languages: {}, messages: [], }; @@ -42,12 +42,9 @@ export class ProjectStore { await this.refresh(); const language = this.data.languages[lang]; - if (!language) { - throw new LanguageNotFound(lang); - } const output: MessageMap = {}; - Object.entries(language).forEach(([key, messageTranslation]) => { + Object.entries(language ?? {}).forEach(([key, messageTranslation]) => { output[key] = { ...messageTranslation }; }); @@ -59,11 +56,15 @@ export class ProjectStore { return this.data.messages; } + toJSON(): StoreData { + return this.data; + } + async updateTranslation(lang: string, id: string, text: string) { await this.refresh(); if (!this.data.languages[lang]) { - throw new LanguageNotFound(lang); + this.data.languages[lang] = {}; } if (!this.data.languages[lang][id]) { diff --git a/webapp/src/store/Store.ts b/webapp/src/store/Store.ts index 826ccc8..08bac24 100644 --- a/webapp/src/store/Store.ts +++ b/webapp/src/store/Store.ts @@ -1,7 +1,84 @@ +import fs from 'fs/promises'; + import { ProjectStore } from '@/store/ProjectStore'; +import MessageAdapterFactory from '@/utils/adapters/MessageAdapterFactory'; +import YamlTranslationAdapter from '@/utils/adapters/YamlTranslationAdapter'; +import { LyraProjectConfig } from '@/utils/lyraConfig'; +import { StoreData } from './types'; + +const FILE_PATH = './store.json'; export class Store { private data = new Map(); + private initialState: Record = {}; + private writes: Promise = Promise.resolve(); + + public static async getProjectStore( + lyraProjectConfig: LyraProjectConfig, + ): Promise { + /** + * As this function is not async, + * no concurrent task executes between checking and updating globalThis. + */ + function initialize(): Promise { + if (!globalThis.store) { + const newStore = new Store(); + globalThis.store = newStore + .loadFromDisk() + // TODO: this catch will never happened, since loadFromDisk catch all + .catch((reason) => { + // Forget the promise, so that the next call will retry. + globalThis.store = null; + throw reason; + }) + .then(() => newStore); + } + return globalThis.store; + } + + const store = await initialize(); + + if (!store.hasProjectStore(lyraProjectConfig.absPath)) { + const initialProjectState = store.initialState[lyraProjectConfig.absPath]; + + const projectStore = new ProjectStore( + MessageAdapterFactory.createAdapter(lyraProjectConfig), + new YamlTranslationAdapter(lyraProjectConfig.absTranslationsPath), + initialProjectState, + ); + store.addProjectStore(lyraProjectConfig.absPath, projectStore); + } + + return store.getProjectStore(lyraProjectConfig.absPath); + } + + public static async persistToDisk(): Promise { + const store = await globalThis.store; + if (!store) { + return; + } + + // Retain initial data if we have not opened a ProjectStore + const payload = {}; + Object.assign(payload, store.initialState, store.toJSON()); + + const json = JSON.stringify(payload); + + // Enqueue the new write, as concurrent writeFile is unsafe. + store.writes = store.writes.finally(() => fs.writeFile(FILE_PATH, json)); + + // Await the end of the queue, which is our write, to resolve. + await store.writes; + } + + public toJSON(): Record { + const data: Record = {}; + this.data.forEach((value, key) => { + data[key] = value.toJSON(); + }); + + return data; + } public addProjectStore(projectPath: string, projectStore: ProjectStore) { this.data.set(projectPath, projectStore); @@ -18,4 +95,13 @@ export class Store { } return projectStore; } + + public async loadFromDisk(): Promise { + try { + const json = await fs.readFile(FILE_PATH); + this.initialState = JSON.parse(json.toString()); + } catch (err) { + // Do nothing. It's fine to start from scratch sometimes. + } + } } diff --git a/webapp/src/store/mergeStoreData.spec.ts b/webapp/src/store/mergeStoreData.spec.ts index fb52028..5f308b7 100644 --- a/webapp/src/store/mergeStoreData.spec.ts +++ b/webapp/src/store/mergeStoreData.spec.ts @@ -125,6 +125,30 @@ describe('mergeStoreData()', () => { const result = mergeStoreData(inMemory, fromRepo); expect(result).toEqual(fromRepo); }); + + it('should not return undefined if both memory and repo are falsy (undefined)', () => { + const inMemory: StoreData = { + languages: { + sv: {}, + }, + messages: [], + }; + + const fromRepo: StoreData = { + languages: { + sv: {}, + }, + messages: [mockMessage('any.message.id', 'Default message')], + }; + + const result = mergeStoreData(inMemory, fromRepo); + expect(result).toStrictEqual({ + languages: { + sv: {}, + }, + messages: [mockMessage('any.message.id', 'Default message')], + }); + }); }); const mockTranslation = ( diff --git a/webapp/src/store/mergeStoreData.ts b/webapp/src/store/mergeStoreData.ts index ff70467..efd23c8 100644 --- a/webapp/src/store/mergeStoreData.ts +++ b/webapp/src/store/mergeStoreData.ts @@ -1,4 +1,5 @@ import { StoreData } from './types'; +import { MessageTranslation } from '@/utils/adapters'; export default function mergeStoreData( inMemory: StoreData, @@ -26,9 +27,13 @@ export default function mergeStoreData( output.languages[lang] = {}; } - output.languages[lang][message.id] = + const messageTranslation: MessageTranslation = inMemory.languages[lang]?.[message.id] || fromRepo.languages[lang]?.[message.id]; + + if (messageTranslation) { + output.languages[lang][message.id] = messageTranslation; + } }); }); diff --git a/webapp/src/types.ts b/webapp/src/types.ts index a05f444..ac8df7a 100644 --- a/webapp/src/types.ts +++ b/webapp/src/types.ts @@ -3,6 +3,12 @@ import { Store } from '@/store/Store'; export type LanguageMap = Map>; declare global { + /** + * Either we promised to complete initialization, + * or we are ready to initialize. + * + * Every concurrent task can await the same initialization. + */ // eslint-disable-next-line - var store: Store; + var store: Promise | null; }