diff --git a/.changeset/kind-pans-check.md b/.changeset/kind-pans-check.md new file mode 100644 index 00000000..7186e830 --- /dev/null +++ b/.changeset/kind-pans-check.md @@ -0,0 +1,5 @@ +--- +'@blinkk/root-cms': patch +--- + +feat: [v2] store compatibility versions in db diff --git a/.changeset/moody-worms-draw.md b/.changeset/moody-worms-draw.md new file mode 100644 index 00000000..39d47cd5 --- /dev/null +++ b/.changeset/moody-worms-draw.md @@ -0,0 +1,10 @@ +--- +'@blinkk/eslint-config-root': major +'@blinkk/root-cms': major +'@blinkk/root': major +'@blinkk/create-root': major +'@blinkk/rds': major +'@blinkk/root-password-protect': major +--- + +chore: [v2] bump version to v2 diff --git a/.changeset/new-dryers-flow.md b/.changeset/new-dryers-flow.md new file mode 100644 index 00000000..7a633494 --- /dev/null +++ b/.changeset/new-dryers-flow.md @@ -0,0 +1,9 @@ +--- +'@blinkk/root-password-protect': patch +'@blinkk/create-root': patch +'@blinkk/root-cms': patch +'@blinkk/root': patch +'@blinkk/rds': patch +--- + +release: [v2] release v2.0.0-rc.1 diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000..78160167 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,25 @@ +{ + "mode": "pre", + "tag": "rc", + "initialVersions": { + "@private/docs": "0.0.0", + "@examples/basepath": "0.0.0", + "@examples/blog": "0.0.0", + "@examples/cms": "0.0.0", + "@examples/minimal": "0.0.0", + "@examples/starter": "0.0.0", + "@blinkk/create-root": "1.3.11", + "@blinkk/eslint-config-root": "0.1.0", + "@blinkk/rds": "1.3.11", + "@blinkk/root": "1.3.11", + "@blinkk/root-cms": "1.3.11", + "@blinkk/root-password-protect": "1.3.11" + }, + "changesets": [ + "kind-pans-check", + "moody-worms-draw", + "new-dryers-flow", + "strong-planes-float", + "twelve-months-explain" + ] +} diff --git a/.changeset/strong-planes-float.md b/.changeset/strong-planes-float.md new file mode 100644 index 00000000..20b7ff3b --- /dev/null +++ b/.changeset/strong-planes-float.md @@ -0,0 +1,6 @@ +--- +'@blinkk/root-cms': patch +'@blinkk/root': patch +--- + +feat: [v2] add compatibility check for v2 translations diff --git a/.changeset/twelve-months-explain.md b/.changeset/twelve-months-explain.md new file mode 100644 index 00000000..882ed4b2 --- /dev/null +++ b/.changeset/twelve-months-explain.md @@ -0,0 +1,5 @@ +--- +'@blinkk/root': patch +--- + +feat: [v2] add "startup" hook to plugins diff --git a/docs/collections/PagesSandbox.schema.ts b/docs/collections/PagesSandbox.schema.ts new file mode 100644 index 00000000..765cf95d --- /dev/null +++ b/docs/collections/PagesSandbox.schema.ts @@ -0,0 +1,8 @@ +import PagesSchema from './Pages.schema.js'; + +export default { + ...PagesSchema, + name: 'Pages [SANDBOX]', + description: 'Sandbox Pages', + url: '/sandbox/[...slug]', +}; diff --git a/docs/layouts/BaseLayout.tsx b/docs/layouts/BaseLayout.tsx index c42e02c1..59f325cd 100644 --- a/docs/layouts/BaseLayout.tsx +++ b/docs/layouts/BaseLayout.tsx @@ -1,4 +1,11 @@ -import {Body, Head, Html, Script, useTranslations} from '@blinkk/root'; +import { + Body, + Head, + Html, + Script, + useRequestContext, + useTranslations, +} from '@blinkk/root'; import {ComponentChildren} from 'preact'; import {GlobalFooter} from '@/components/GlobalFooter/GlobalFooter.js'; import {GlobalHeader} from '@/components/GlobalHeader/GlobalHeader.js'; @@ -39,9 +46,10 @@ export function BaseLayout(props: BaseLayoutProps) { width: 1200, jpg: true, }); + const ctx = useRequestContext(); return ( - + {t(title)} diff --git a/docs/layouts/base.tsx b/docs/layouts/base.tsx index ad29fce1..0cd4cd8b 100644 --- a/docs/layouts/base.tsx +++ b/docs/layouts/base.tsx @@ -1,6 +1,7 @@ +/* eslint-disable import/order */ + import {Head} from '@blinkk/root'; import {ComponentChildren} from 'preact'; - import {GlobalFooter} from '@/templates/global-footer/global-footer.js'; import {GlobalHeader} from '@/templates/global-header/global-header.js'; import '@/styles/global.scss'; diff --git a/docs/root-cms.d.ts b/docs/root-cms.d.ts index a50c4247..3ebc3ace 100644 --- a/docs/root-cms.d.ts +++ b/docs/root-cms.d.ts @@ -196,6 +196,27 @@ export interface PagesFields { /** Generated from `/collections/Pages.schema.ts`. */ export type PagesDoc = RootCMSDoc; +/** Generated from `/collections/PagesSandbox.schema.ts`. */ +export interface PagesSandboxFields { + /** Meta */ + meta?: { + /** Title. Page title. */ + title?: string; + /** Description. Description for SEO and social shares. */ + description?: string; + /** Image. Meta image for social shares. Recommended: 1400x600 JPG. */ + image?: RootCMSImage; + }; + /** Content */ + content?: { + /** Modules. Compose the page by adding one or more modules. */ + modules?: RootCMSOneOf[]; + }; +} + +/** Generated from `/collections/PagesSandbox.schema.ts`. */ +export type PagesSandboxDoc = RootCMSDoc; + /** Generated from `/components/Button/Button.schema.ts`. */ export interface ButtonFields { /** Button Options */ @@ -329,4 +350,4 @@ export interface TemplatePoweredByFields { }[]; /** Body copy */ body?: RootCMSRichText; -} \ No newline at end of file +} diff --git a/docs/routes/[[...page]].tsx b/docs/routes/[[...page]].tsx index 008abda2..df0559a7 100644 --- a/docs/routes/[[...page]].tsx +++ b/docs/routes/[[...page]].tsx @@ -1,10 +1,10 @@ +import {cmsRoute} from '@blinkk/root-cms'; import { PageModuleFields, PageModules, -} from '@/components/PageModules/PageModules'; -import {BaseLayout} from '@/layouts/BaseLayout'; -import {PagesDoc} from '@/root-cms'; -import {cmsRoute} from '@/utils/cms-route'; +} from '@/components/PageModules/PageModules.js'; +import {BaseLayout} from '@/layouts/BaseLayout.js'; +import {PagesDoc} from '@/root-cms.js'; export interface PageProps { doc: PagesDoc; diff --git a/docs/routes/blog.tsx b/docs/routes/blog.tsx index 56292189..25a8f875 100644 --- a/docs/routes/blog.tsx +++ b/docs/routes/blog.tsx @@ -1,5 +1,5 @@ -import {cmsRoute} from '@/utils/cms-route'; -import {default as Page} from './blog/[...blog]'; +import {cmsRoute} from '@blinkk/root-cms'; +import {default as Page} from './blog/[...blog].js'; // TODO(stevenle): Create a blog listing page when we have more than 1 post. export default Page; diff --git a/docs/routes/blog/[...blog].tsx b/docs/routes/blog/[...blog].tsx index 0861b012..5b0b59f5 100644 --- a/docs/routes/blog/[...blog].tsx +++ b/docs/routes/blog/[...blog].tsx @@ -1,10 +1,10 @@ -import {CopyBlock} from '@/blocks/CopyBlock/CopyBlock'; -import Block from '@/components/Block/Block'; -import {Container} from '@/components/Container/Container'; -import {Text} from '@/components/Text/Text'; -import {BaseLayout} from '@/layouts/BaseLayout'; -import {BlogPostsDoc} from '@/root-cms'; -import {cmsRoute} from '@/utils/cms-route'; +import {cmsRoute} from '@blinkk/root-cms'; +import {CopyBlock} from '@/blocks/CopyBlock/CopyBlock.js'; +import Block from '@/components/Block/Block.js'; +import {Container} from '@/components/Container/Container.js'; +import {Text} from '@/components/Text/Text.js'; +import {BaseLayout} from '@/layouts/BaseLayout.js'; +import {BlogPostsDoc} from '@/root-cms.js'; import styles from './[...blog].module.scss'; export interface PageProps { diff --git a/docs/routes/guide/[[...guide]].tsx b/docs/routes/guide/[[...guide]].tsx index 62cbd581..140e4d6a 100644 --- a/docs/routes/guide/[[...guide]].tsx +++ b/docs/routes/guide/[[...guide]].tsx @@ -1,4 +1,5 @@ import {RequestContext, useRequestContext, useTranslations} from '@blinkk/root'; +import {cmsRoute} from '@blinkk/root-cms'; import {IconLayoutSidebarLeftExpand} from '@tabler/icons-preact'; import Block from '@/components/Block/Block.js'; import {RichText} from '@/components/RichText/RichText.js'; @@ -8,7 +9,6 @@ import {LogoToggle} from '@/islands/LogoToggle/LogoToggle.js'; import {BaseLayout} from '@/layouts/BaseLayout.js'; import {GuideDoc} from '@/root-cms.js'; import {joinClassNames} from '@/utils/classes.js'; -import {cmsRoute} from '@/utils/cms-route.js'; import styles from './[[...guide]].module.scss'; const GUIDE_LINKS = [ diff --git a/docs/routes/sandbox/[sandbox].tsx b/docs/routes/sandbox/[sandbox].tsx new file mode 100644 index 00000000..71f89422 --- /dev/null +++ b/docs/routes/sandbox/[sandbox].tsx @@ -0,0 +1,9 @@ +import {cmsRoute} from '@blinkk/root-cms'; +import Page from '../[[...page]].js'; + +export default Page; + +export const {handle} = cmsRoute({ + collection: 'PagesSandbox', + slugParam: 'sandbox', +}); diff --git a/packages/create-root/CHANGELOG.md b/packages/create-root/CHANGELOG.md index 82a6616a..bada6808 100644 --- a/packages/create-root/CHANGELOG.md +++ b/packages/create-root/CHANGELOG.md @@ -1,5 +1,17 @@ # @blinkk/create-root +## 2.0.0-rc.1 + +### Patch Changes + +- release: [v2] release v2.0.0-rc.1 + +## 2.0.0-rc.0 + +### Major Changes + +- 1dfa6dd: chore: [v2] bump version to v2 + ## 1.3.12 ## 1.3.11 diff --git a/packages/create-root/package.json b/packages/create-root/package.json index c6596a6b..267ecd07 100644 --- a/packages/create-root/package.json +++ b/packages/create-root/package.json @@ -1,6 +1,6 @@ { "name": "@blinkk/create-root", - "version": "1.3.12", + "version": "2.0.0-rc.1", "description": "", "author": "s@blinkk.com", "license": "MIT", diff --git a/packages/eslint-config-root/CHANGELOG.md b/packages/eslint-config-root/CHANGELOG.md index d987cf26..5cc2f749 100644 --- a/packages/eslint-config-root/CHANGELOG.md +++ b/packages/eslint-config-root/CHANGELOG.md @@ -1,5 +1,11 @@ # @blinkk/eslint-config-root +## 1.0.0-rc.0 + +### Major Changes + +- 1dfa6dd: chore: [v2] bump version to v2 + ## 0.1.0 ### Minor Changes diff --git a/packages/eslint-config-root/index.js b/packages/eslint-config-root/index.js index 37bf0f2d..5a5423c4 100644 --- a/packages/eslint-config-root/index.js +++ b/packages/eslint-config-root/index.js @@ -57,14 +57,26 @@ module.exports = { 'parent', 'sibling', 'index', + 'unknown', ], pathGroups: [ { pattern: '@/**', group: 'internal', }, + { + pattern: '**/*.{css,scss}', + group: 'unknown', + }, + { + pattern: '*.{css,scss}', + group: 'unknown', + patternOptions: {matchBase: true}, + position: 'after', + }, ], pathGroupsExcludedImportTypes: ['builtin'], + warnOnUnassignedImports: true, 'newlines-between': 'ignore', alphabetize: { order: 'asc', diff --git a/packages/eslint-config-root/package.json b/packages/eslint-config-root/package.json index 7a9f5724..26447b81 100644 --- a/packages/eslint-config-root/package.json +++ b/packages/eslint-config-root/package.json @@ -1,6 +1,6 @@ { "name": "@blinkk/eslint-config-root", - "version": "0.1.0", + "version": "1.0.0-rc.0", "description": "ESLint config for Root.js projects.", "author": "s@blinkk.com", "license": "MIT", diff --git a/packages/rds/CHANGELOG.md b/packages/rds/CHANGELOG.md index 4892cedc..9e358ecf 100644 --- a/packages/rds/CHANGELOG.md +++ b/packages/rds/CHANGELOG.md @@ -1,5 +1,26 @@ # @blinkk/rds +## 2.0.0-rc.1 + +### Patch Changes + +- release: [v2] release v2.0.0-rc.1 +- Updated dependencies + - @blinkk/root@2.0.0-rc.1 + +## 2.0.0-rc.0 + +### Major Changes + +- 1dfa6dd: chore: [v2] bump version to v2 + +### Patch Changes + +- Updated dependencies [1dfa6dd] +- Updated dependencies [f5bd041] +- Updated dependencies [053bb1c] + - @blinkk/root@2.0.0-rc.0 + ## 1.3.12 ### Patch Changes diff --git a/packages/rds/package.json b/packages/rds/package.json index 20e469cb..30a269ee 100644 --- a/packages/rds/package.json +++ b/packages/rds/package.json @@ -1,7 +1,7 @@ { "name": "@blinkk/rds", "description": "Root.js Design System", - "version": "1.3.12", + "version": "2.0.0-rc.1", "author": "s@blinkk.com", "license": "MIT", "engines": { @@ -32,7 +32,7 @@ "typescript": "^5.0.4" }, "peerDependencies": { - "@blinkk/root": "1.3.12", + "@blinkk/root": "2.0.0-rc.1", "preact": "*" }, "dependencies": { diff --git a/packages/root-cms/CHANGELOG.md b/packages/root-cms/CHANGELOG.md index b67dd18f..17327bad 100644 --- a/packages/root-cms/CHANGELOG.md +++ b/packages/root-cms/CHANGELOG.md @@ -1,5 +1,28 @@ # @blinkk/root-cms +## 2.0.0-rc.1 + +### Patch Changes + +- release: [v2] release v2.0.0-rc.1 +- Updated dependencies + - @blinkk/root@2.0.0-rc.1 + +## 2.0.0-rc.0 + +### Major Changes + +- 1dfa6dd: chore: [v2] bump version to v2 + +### Patch Changes + +- f5bd041: feat: [v2] store compatibility versions in db +- f5bd041: feat: [v2] add compatibility check for v2 translations +- Updated dependencies [1dfa6dd] +- Updated dependencies [f5bd041] +- Updated dependencies [053bb1c] + - @blinkk/root@2.0.0-rc.0 + ## 1.3.12 ### Patch Changes diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index 5b16ef65..484585b0 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import {Plugin, RootConfig} from '@blinkk/root'; +import type {RootConfig} from '@blinkk/root'; import {App} from 'firebase-admin/app'; import { FieldValue, @@ -8,7 +8,9 @@ import { Timestamp, WriteBatch, } from 'firebase-admin/firestore'; -import {CMSPlugin} from './plugin.js'; +import {hashStr, normalizeStr} from '../shared/strings.js'; +import {CMSPlugin, getCmsPlugin} from './plugin.js'; +import {TranslationsManager} from './translations-manager.js'; export interface Doc { /** The id of the doc, e.g. "Pages/foo-bar". */ @@ -37,6 +39,24 @@ export interface Doc { fields: Fields; } +export interface TranslationsDoc { + id: string; + sys: { + modifiedAt: Timestamp; + modifiedBy: string; + publishedAt?: Timestamp; + publishedBy?: string; + linkedSheet?: { + spreadsheetId: string; + gid: number; + linkedAt: Timestamp; + linkedBy: string; + }; + tags?: string[]; + }; + strings: TranslationsMap; +} + export type DocMode = 'draft' | 'published'; export type UserRole = 'ADMIN' | 'EDITOR' | 'VIEWER'; @@ -222,11 +242,7 @@ export class RootCMSClient { options: GetDocOptions ): Promise { const mode = options.mode; - const modeCollection = mode === 'draft' ? 'Drafts' : 'Published'; - // Slugs with slashes are encoded as `--` in the DB. - slug = slug.replaceAll('/', '--'); - const dbPath = `Projects/${this.projectId}/Collections/${collectionId}/${modeCollection}/${slug}`; - const docRef = this.db.doc(dbPath); + const docRef = this.dbDocRef(collectionId, slug, {mode}); const doc = await docRef.get(); if (doc.exists) { return doc.data(); @@ -234,6 +250,41 @@ export class RootCMSClient { return null; } + dbCollectionDocsPath( + collectionId: string, + options: {mode: 'draft' | 'published'} + ) { + let modeCollection = ''; + if (options.mode === 'draft') { + modeCollection = 'Drafts'; + } else if (options.mode === 'published') { + modeCollection = 'Published'; + } else { + throw new Error(`unknown mode: ${options.mode}`); + } + return `Projects/${this.projectId}/Collections/${collectionId}/${modeCollection}`; + } + + dbDocPath( + collectionId: string, + slug: string, + options: {mode: 'draft' | 'published'} + ) { + const collectionDocsPath = this.dbCollectionDocsPath(collectionId, options); + // Slugs with slashes are encoded as `--` in the DB. + const normalizedSlug = slug.replaceAll('/', '--'); + return `${collectionDocsPath}/${normalizedSlug}`; + } + + dbDocRef( + collectionId: string, + slug: string, + options: {mode: 'draft' | 'published'} + ) { + const docPath = this.dbDocPath(collectionId, slug, options); + return this.db.doc(docPath); + } + /** * Saves draft data to a doc. * @@ -434,6 +485,9 @@ export class RootCMSClient { }); batchCount += 1; + this.publishTranslationsDoc(doc.id, {batch}); + batchCount += 2; + publishedDocs.push(doc); if (batchCount >= 400) { @@ -546,6 +600,9 @@ export class RootCMSClient { }); batchCount += 1; + this.publishTranslationsDoc(doc.id, {batch}); + batchCount += 2; + publishedDocs.push(doc); if (batchCount >= 400) { @@ -587,6 +644,61 @@ export class RootCMSClient { } } + /** + * Returns a `TranslationsManager` object for managing translations. + * + * To get translations: + * ``` + * await tm.loadTranslations({ids: ['Global/strings', 'Pages/index'], locales: ['es']}) + * + * ``` + */ + getTranslationsManager(): TranslationsManager { + return new TranslationsManager(this); + } + + async publishTranslationsDoc( + translationsId: string, + options?: {batch?: WriteBatch; publishedBy?: string} + ) { + const docSlug = translationsId.replaceAll('/', '--'); + const draftRef = this.db.doc( + `Projects/${this.projectId}/TranslationsManager/draft/Translations/${docSlug}` + ); + const snapshot = await draftRef.get(); + + if (!snapshot.exists) { + // Ignore missing translations. + console.warn(`translations ${translationsId} does not exist`); + return; + } + + // If the translations publishing is tied to another batch request (e.g. doc + // publishing), the batch request will be committed upstream. + const commitBatch = !options?.batch; + + const batch = options?.batch || this.db.batch(); + batch.update(draftRef, { + 'sys.publishedAt': Timestamp.now(), + 'sys.publishedBy': options?.publishedBy || 'root-cms-client', + }); + + const publishedRef = this.db.doc( + `Projects/${this.projectId}/TranslationsManager/published/Translations/${docSlug}` + ); + const data = {...snapshot.data()}; + if (!data.sys) { + data.sys = {}; + } + data.sys.publishedAt = Timestamp.now(); + data.sys.publishedBy = options?.publishedBy || 'root-cms-client'; + batch.set(publishedRef, data); + if (commitBatch) { + await batch.commit(); + this.logAction('translations.publish', {metadata: {translationsId}}); + } + } + /** * Checks if a doc is currently "locked" for publishing. */ @@ -603,80 +715,79 @@ export class RootCMSClient { } /** - * Loads translations saved in the translations collection, optionally - * filtered by tag. + * Loads all published strings in the db. * - * Returns a map like: * ``` * { * "": {"source": "Hello", "es": "Hola", "fr": "Bonjour"}, * } * ``` + * + * @deprecated Use `createBatchRequest()` to fetch draft/published translations. */ async loadTranslations( options?: LoadTranslationsOptions ): Promise { - const dbPath = `Projects/${this.projectId}/Translations`; + const dbPath = `Projects/${this.projectId}/TranslationsManager/published/Translations`; + const translationsMap: TranslationsMap = {}; + + const addTranslations = (hash: string, translations: Translation) => { + translationsMap[hash] = Object.assign( + translationsMap[hash] || {}, + translations + ); + }; + let query: Query = this.db.collection(dbPath); - if (options?.tags) { - query = query.where('tags', 'array-contains-any', options.tags); + if (options?.tags && options.tags.length > 0) { + query = query.where('sys.tags', 'array-contains-any', options.tags); } - - const querySnapshot = await query.get(); - const translationsMap: TranslationsMap = {}; - querySnapshot.forEach((doc) => { - const hash = doc.id; - translationsMap[hash] = doc.data() as Translation; + const snapshot = await query.get(); + snapshot.forEach((doc) => { + const data = doc.data(); + const strings = (data.strings || {}) as Record; + Object.entries(strings).forEach(([hash, translations]) => { + addTranslations(hash, translations); + }); }); + return translationsMap; } - /** - * Saves a map of translations, e.g.: - * ``` - * await client.saveTranslations({ - * "Hello": {"es": "Hola", "fr": "Bonjour"}, - * }); - * ``` - */ - async saveTranslations( - translations: { - [source: string]: {[locale: string]: string}; - }, - tags?: string[] + async saveDraftTranslations( + translationsId: string, + translations: {[source: string]: {[locale: string]: string}}, + options?: {modifiedby?: string} ) { - const translationsPath = `Projects/${this.projectId}/Translations`; - const batch = this.db.batch(); - let batchCount = 0; - Object.entries(translations).forEach(([source, sourceTranslations]) => { - const hash = this.getTranslationKey(source); - const translationRef = this.db.doc(`${translationsPath}/${hash}`); - const data: any = { - ...sourceTranslations, - source: this.normalizeString(source), - }; - if (tags) { - data.tags = tags; - } - batch.set(translationRef, data, {merge: true}); - batchCount += 1; + const docSlug = translationsId.replaceAll('/', '--'); + const docPath = `Projects/${this.projectId}/TranslationsManager/draft/${docSlug}`; + const docRef = this.db.doc(docPath); + + const snapshot = await docRef.get(); + const data = snapshot.data() || { + id: translationsId.replaceAll('--', '/'), + sys: {}, + strings: {}, + }; + data.sys.modifiedAt = Timestamp.now(); + data.sys.modifiedBy = options?.modifiedby || 'root-cms-client'; + + const strings = data.strings || {}; + Object.entries(translations).forEach(([source, row]) => { + const normalizedSource = this.normalizeString(source); + const hash = this.getTranslationKey(normalizedSource); + strings[hash] = {...strings[hash], ...row, source: normalizedSource}; }); - if (batchCount > 500) { - throw new Error('up to 500 translations can be saved at a time.'); - } - await batch.commit(); + data.strings = strings; + await docRef.set(data); + this.logAction('translations.save', {metadata: {translationsId}}); } /** - * Returns the "key" used for a translation as stored in the db. Translations - * are stored under `Projects//Translations/`. + * Returns the hash "key" used for a translation as stored in the db. */ - getTranslationKey(source: string) { - const sha1 = crypto - .createHash('sha1') - .update(this.normalizeString(source)) - .digest('hex'); - return sha1; + getTranslationKey(str: string) { + return hashStr(str); } /** @@ -685,11 +796,7 @@ export class RootCMSClient { * - Removes spaces at the end of any line */ normalizeString(str: string) { - const lines = String(str) - .trim() - .split('\n') - .map((line) => line.trimEnd()); - return lines.join('\n'); + return normalizeStr(str); } /** @@ -853,15 +960,8 @@ export class RootCMSClient { options?: {mode?: 'draft' | 'published'} ): Promise | null> { const mode = options?.mode || 'published'; - if (!(mode === 'draft' || mode === 'published')) { - throw new Error(`invalid mode: ${mode}`); - } - if (!dataSourceId || dataSourceId.includes('/')) { - throw new Error(`invalid data source id: ${dataSourceId}`); - } - const dbPath = `Projects/${this.projectId}/DataSources/${dataSourceId}/Data/${mode}`; - const docRef = this.db.doc(dbPath); + const docRef = this.dbDataSourceDataRef(dataSourceId, {mode}); const doc = await docRef.get(); if (doc.exists) { return doc.data() as DataSourceData; @@ -869,6 +969,50 @@ export class RootCMSClient { return null; } + dbDataSourceDataPath( + dataSourceId: string, + options: {mode: 'draft' | 'published'} + ) { + if (!dataSourceId || dataSourceId.includes('/')) { + throw new Error(`invalid data source id: ${dataSourceId}`); + } + const mode = options.mode; + if (!(mode === 'draft' || mode === 'published')) { + throw new Error(`invalid mode: ${mode}`); + } + const dbPath = `Projects/${this.projectId}/DataSources/${dataSourceId}/Data/${mode}`; + return dbPath; + } + + dbDataSourceDataRef( + dataSourceId: string, + options: {mode: 'draft' | 'published'} + ) { + const dbPath = this.dbDataSourceDataPath(dataSourceId, options); + return this.db.doc(dbPath); + } + + dbTranslationsPath( + translationsId: string, + options: {mode: 'draft' | 'published'} + ) { + const mode = options.mode; + if (!(mode === 'draft' || mode === 'published')) { + throw new Error(`invalid mode: ${mode}`); + } + const slug = translationsId.replaceAll('/', '--'); + const dbPath = `Projects/${this.projectId}/TranslationsManager/${mode}/Translations/${slug}`; + return dbPath; + } + + dbTranslationsRef( + translationsId: string, + options: {mode: 'draft' | 'published'} + ) { + const dbPath = this.dbTranslationsPath(translationsId, options); + return this.db.doc(dbPath); + } + /** * Gets the user's role from the project's ACL. */ @@ -950,6 +1094,276 @@ export class RootCMSClient { } } } + + createBatchRequest(options: BatchRequestOptions): BatchRequest { + return new BatchRequest(this, options); + } +} + +export interface BatchRequestOptions { + mode: 'draft' | 'published'; + /** + * Whether to automatically fetch translations for the docs retrieved in the + * request. + */ + translate?: boolean; +} + +export interface BatchRequestQuery { + queryId: string; + collectionId: string; + queryOptions?: BatchRequestQueryOptions; +} + +export interface BatchRequestQueryOptions { + offset?: number; + limit?: number; + orderBy?: string; + orderByDirection?: 'asc' | 'desc'; + query?: (query: Query) => Query; +} + +export class BatchRequest { + cmsClient: RootCMSClient; + private options: BatchRequestOptions; + private db: Firestore; + private docIds: string[] = []; + private dataSourceIds: string[] = []; + private queries: BatchRequestQuery[] = []; + private translationsIds: string[] = []; + + constructor(cmsClient: RootCMSClient, options: BatchRequestOptions) { + this.cmsClient = cmsClient; + this.db = cmsClient.db; + this.options = options; + } + + /** + * Adds a doc to the batch request. + */ + addDoc(docId: string) { + this.docIds.push(docId); + } + + /** + * Adds a data source to the batch request. + */ + addDataSource(dataSourceId: string) { + this.dataSourceIds.push(dataSourceId); + } + + /** + * Adds a collection-based query to the batch request. + */ + addQuery( + queryId: string, + collectionId: string, + queryOptions?: BatchRequestQueryOptions + ) { + this.queries.push({ + queryId: queryId, + collectionId: collectionId, + queryOptions: queryOptions, + }); + } + + /** + * Adds a translation file to the request. + */ + addTranslations(translationsId: string) { + this.translationsIds.push(translationsId); + } + + /** + * Fetches data from the DB. + */ + async fetch(): Promise { + const res = new BatchResponse(); + + const promises = [ + this.fetchDocs(res), + this.fetchQueries(res), + this.fetchDataSources(res), + ]; + // If `options.translate` is disabled and translations are requested, + // fetch the translations in parallel with the other docs. + if (!this.options.translate && this.translationsIds.length > 0) { + promises.push(this.fetchTranslations(res)); + } + + await Promise.all(promises); + + // If `options.translate` is enabled, the fetchX() methods will + // automatically add each doc's translations id to the request, so + // translations should be fetched after all the other docs are fetched. + if (this.translationsIds.length > 0) { + await this.fetchTranslations(res); + } + + return res; + } + + private async fetchDocs(res: BatchResponse) { + if (this.docIds.length === 0) { + return; + } + const docRefs = this.docIds.map((docId) => { + const [collectionId, slug] = docId.split('/'); + return this.cmsClient.dbDocRef(collectionId, slug, { + mode: this.options.mode, + }); + }); + const docs = await this.db.getAll(...docRefs); + this.docIds.forEach((docId, i) => { + const doc = docs[i]; + if (!doc.exists) { + console.warn(`doc "${docId}" does not exist`); + return; + } + const docData = unmarshalData(doc.data()) as Doc; + res.docs[docId] = docData; + + if (this.options.translate) { + this.addTranslations(docId); + } + }); + } + + private async fetchQueries(res: BatchResponse) { + if (this.queries.length === 0) { + return; + } + const mode = this.options.mode; + + const handleQuery = async (queryItem: BatchRequestQuery) => { + const docsPath = this.cmsClient.dbCollectionDocsPath( + queryItem.collectionId, + {mode} + ); + const queryOptions = queryItem.queryOptions || {}; + let query: Query = this.db.collection(docsPath); + if (queryOptions.limit) { + query = query.limit(queryOptions.limit); + } + if (queryOptions.offset) { + query = query.offset(queryOptions.offset); + } + if (queryOptions.orderBy) { + query = query.orderBy( + queryOptions.orderBy, + queryOptions.orderByDirection + ); + } + if (queryOptions.query) { + query = queryOptions.query(query); + } + const results = await query.get(); + const docs: Doc[] = []; + results.forEach((result) => { + const doc = unmarshalData(result.data()) as Doc; + docs.push(doc); + }); + res.queries[queryItem.queryId] = docs; + }; + + await Promise.all(this.queries.map((queryItem) => handleQuery(queryItem))); + } + + private async fetchDataSources(res: BatchResponse) { + if (this.dataSourceIds.length === 0) { + return; + } + const docRefs = this.dataSourceIds.map((dataSourceId) => { + return this.cmsClient.dbDataSourceDataRef(dataSourceId, { + mode: this.options.mode, + }); + }); + const docs = await this.db.getAll(...docRefs); + this.dataSourceIds.forEach((dataSourceId, i) => { + const doc = docs[i]; + if (!doc.exists) { + console.warn(`"data source "${dataSourceId}" does not exist`); + return; + } + res.dataSources[dataSourceId] = doc.data() as DataSourceData; + }); + } + + private async fetchTranslations(res: BatchResponse) { + if (this.translationsIds.length === 0) { + return; + } + + const docRefs = this.translationsIds.map((translationsId) => { + return this.cmsClient.dbTranslationsRef(translationsId, { + mode: this.options.mode, + }); + }); + const docs = await this.db.getAll(...docRefs); + this.translationsIds.forEach((translationsId, i) => { + const doc = docs[i]; + if (!doc.exists) { + // console.warn(`translations "${translationsId}" does not exist`); + return; + } + res.translations[translationsId] = doc.data() as TranslationsDoc; + }); + } +} + +export class BatchResponse { + docs: Record = {}; + queries: Record = {}; + dataSources: Record = {}; + translations: Record = {}; + + /** + * Returns a map of translations for a given locale or locale fallbacks. + * + * The input is either a single locale (e.g. "de") or an array of locales + * representing the fallback tree, e.g. ["en-CA", "en-GB", "en"]. + * + * The returned value is a flat map of source string to translated string, + * e.g.: + * {"": ""} + */ + getTranslations(locale: string | string[]): LocaleTranslations { + const translationsMap = this.getTranslationsMap(); + const translations = translationsForLocale(translationsMap, locale); + return translations; + } + + /** + * Merges the strings from all translations files retrieved in the request. + * The returned value is a map of string to translations, e.g.: + * + * {"": {"source": "", "": ""}} + */ + private getTranslationsMap(): TranslationsMap { + // Load translations in the following order: + // - generic translations (e.g. "global") + // - docs returned from queries (e.g. "list all blog posts") + // - specific docs (e.g. "Pages/index") + const translationsDocs = Object.values(this.translations).reverse(); + + // Consolidate the strings from all of the translations files. + // {"": {"source": "", "": ""}} + const translationsMap: TranslationsMap = {}; + for (const translationsDoc of translationsDocs) { + const strings = translationsDoc.strings || {}; + for (const hash in strings) { + const translations = strings[hash]; + translationsMap[hash] ??= {source: translations.source}; + for (const locale in translations) { + if (locale !== 'source' && translations[locale]) { + translationsMap[hash][locale] = translations[locale]; + } + } + } + } + + return translationsMap; + } } /** @@ -968,15 +1382,6 @@ export function isRichTextData(data: any) { ); } -export function getCmsPlugin(rootConfig: RootConfig): CMSPlugin { - const plugins: Plugin[] = rootConfig.plugins || []; - const plugin = plugins.find((plugin) => plugin.name === 'root-cms'); - if (!plugin) { - throw new Error('could not find root-cms plugin config in root.config.ts'); - } - return plugin as CMSPlugin; -} - /** * Walks the data tree and converts any array of objects into "array objects" * for storage in firestore. @@ -1126,12 +1531,24 @@ function randString(len: number): string { */ export function translationsForLocale( translationsMap: TranslationsMap, - locale: string + locale: string | string[] ) { const localeTranslations: LocaleTranslations = {}; + + const fallbackLocales = Array.isArray(locale) ? locale : [locale]; + if (!fallbackLocales.includes('en')) { + fallbackLocales.push('en'); + } + Object.values(translationsMap).forEach((string) => { const source = string.source; - const translation = string[locale] || string.en || string.source; + let translation = source; + for (const fallbackLocale of fallbackLocales) { + if (string[fallbackLocale]) { + translation = string[fallbackLocale]; + break; + } + } localeTranslations[source] = translation; }); return localeTranslations; @@ -1178,3 +1595,5 @@ export function parseDocId(docId: string) { } return {collection, slug}; } + +export {getCmsPlugin}; diff --git a/docs/utils/cms-route.ts b/packages/root-cms/core/cms-route.ts similarity index 64% rename from docs/utils/cms-route.ts rename to packages/root-cms/core/cms-route.ts index b5dfd3c1..744eacd2 100644 --- a/docs/utils/cms-route.ts +++ b/packages/root-cms/core/cms-route.ts @@ -6,7 +6,7 @@ import { Response, RouteParams, } from '@blinkk/root'; -import {RootCMSClient, translationsForLocale} from '@blinkk/root-cms/client'; +import {BatchRequest, RootCMSClient} from './client.js'; export type CMSRequest = Request & { cmsClient: RootCMSClient; @@ -37,6 +37,16 @@ export interface CMSRouteOptions { */ slug?: string; + /** + * Hook that allows callers to modify the Root CMS `BatchRequest` object. If a + * new `BatchRequest` is returned, it will replace the default one created by + * `cmsRoute()`. + */ + preRequestHook?: ( + batchRequest: BatchRequest, + context: CMSRouteContext + ) => void | BatchRequest; + /** * Callback function that returns a map of Promises that contain fetched data. * Once the promise is resolved, the values are injected into page's props @@ -55,11 +65,6 @@ export interface CMSRouteOptions { */ setResponseHeaders?: (req: Request, res: Response) => void; - /** - * Translations configuration. - */ - translations?: (context: CMSRouteContext) => {tags?: string[]}; - /** * Sets Cache-Control header to `private`. */ @@ -72,20 +77,25 @@ export interface CMSRouteOptions { } export interface CMSDoc { + id: string; + slug: string; sys?: { locales?: string[]; }; } +/** + * Utility for generating SSR and SSG handlers in a Root route file. + */ export function cmsRoute(options: CMSRouteOptions) { - let cmsClient: RootCMSClient = null; + let cmsClient: RootCMSClient | null = null; function getSlug(params: RouteParams) { if (options.slug) { return options.slug; } const slugParam = options.slugParam || 'slug'; - return params[slugParam] || 'index'; + return (params[slugParam] || 'index').replaceAll('/', '--'); } async function fetchData( @@ -98,27 +108,73 @@ export function cmsRoute(options: CMSRouteOptions) { return resolvePromisesMap(promisesMap); } - async function generateProps(routeContext: CMSRouteContext, locale: string) { + async function generateProps( + routeContext: CMSRouteContext, + preferredLocale: string | ((doc: CMSDoc) => string) + ) { const {slug, mode} = routeContext; - const translationsTags = ['common', `${options.collection}/${slug}`]; - if (options.translations) { - const tags = options.translations(routeContext)?.tags || []; - translationsTags.push(...tags); + + const primaryDocId = `${options.collection}/${slug}`; + let batchRequest = routeContext.cmsClient.createBatchRequest({ + mode, + translate: true, + }); + batchRequest.addDoc(primaryDocId); + + // Call the pre-request hook to allow users to modify the batch request. + if (options.preRequestHook) { + const overridedBatchRequest = options.preRequestHook( + batchRequest, + routeContext + ); + if (overridedBatchRequest) { + batchRequest = overridedBatchRequest; + } } - const [doc, translationsMap, data] = await Promise.all([ - cmsClient.getDoc(options.collection, slug, { - mode, - }), - cmsClient.loadTranslations({tags: translationsTags}), + // Fetch the Root CMS BatchRequest in parallel with any other data the + // caller needs to fetch to render the route. + const [batchRes, data] = await Promise.all([ + batchRequest.fetch(), fetchData(routeContext), ]); + const doc = batchRes.docs[primaryDocId]; if (!doc) { return {notFound: true}; } - const translations = translationsForLocale(translationsMap, locale); + // Determine the preferred locale to render. + let locale: string; + if (typeof preferredLocale === 'string') { + locale = preferredLocale; + } else { + locale = preferredLocale(doc); + } + const docLocales = doc.sys?.locales || ['en']; + if (!locale || !docLocales.includes(locale)) { + return {notFound: true}; + } + + // From the preferred locale, generate a translations map. + const i18nFallbacks = + routeContext.cmsClient.rootConfig.i18n?.fallbacks || {}; + const translationFallbackLocales = i18nFallbacks[locale] || [locale]; + const translations = batchRes.getTranslations(translationFallbackLocales); + let props: any = {...data, locale, mode, slug, doc}; + + // For SSR handlers, inject the user's country of origin to props. + if (routeContext.req) { + const country = + getFirstQueryParam(routeContext.req, 'gl') || + routeContext.req.get('x-country-code') || + routeContext.req.get('x-appengine-country') || + null; + props.country = country; + } + + // Call the pre-render hook which allows a caller to modify props before + // it is passed to the route component. if (options.preRenderHook) { props = await options.preRenderHook(props, routeContext); } @@ -127,8 +183,8 @@ export function cmsRoute(options: CMSRouteOptions) { } // SSG handlers are disabled by default. Pass `{enableSSG: true}` to enable. - let getStaticPaths: GetStaticPaths = null; - let getStaticProps: GetStaticProps = null; + let getStaticPaths: GetStaticPaths | null = null; + let getStaticProps: GetStaticProps | null = null; if (options.enableSSG) { getStaticPaths = async (ctx) => { @@ -138,13 +194,12 @@ export function cmsRoute(options: CMSRouteOptions) { if (!cmsClient) { cmsClient = new RootCMSClient(ctx.rootConfig); } - // TODO(stevenle): Add support for mode. const mode = 'published'; - const res = await cmsClient.listDocs(options.collection, {mode}); - const ssgPaths = []; - res.docs.forEach((doc: {slug: string}) => { + const res = await cmsClient.listDocs(options.collection, {mode}); + const ssgPaths: Array<{params: Record}> = []; + res.docs.forEach((doc) => { const params: Record = {}; - params[options.slugParam] = doc.slug; + params[options.slugParam!] = doc.slug; ssgPaths.push({params}); }); return {paths: ssgPaths}; @@ -155,10 +210,8 @@ export function cmsRoute(options: CMSRouteOptions) { cmsClient = new RootCMSClient(ctx.rootConfig); } const slug = getSlug(ctx.params); - // TODO(stevenle): Add support for mode. const mode = 'published'; - const routeContext: CMSRouteContext = {req: null, slug, mode, cmsClient}; - + const routeContext: CMSRouteContext = {slug, mode, cmsClient}; return generateProps(routeContext, ctx.params.$locale); }; } @@ -169,9 +222,9 @@ export function cmsRoute(options: CMSRouteOptions) { getStaticProps: getStaticProps, // SSR handler. - handle: async (req, res) => { + handle: async (req: CMSRequest, res: Response) => { if (!cmsClient) { - cmsClient = new RootCMSClient(req.rootConfig); + cmsClient = new RootCMSClient(req.rootConfig!); } req.cmsClient = cmsClient; const ctx = req.handlerContext as HandlerContext; @@ -183,43 +236,25 @@ export function cmsRoute(options: CMSRouteOptions) { const mode = String(req.query.preview) === 'true' ? 'draft' : 'published'; const routeContext: CMSRouteContext = {req, slug, mode, cmsClient}; - const translationsTags = ['common', `${options.collection}/${slug}`]; - if (options.translations) { - const tags = options.translations(routeContext)?.tags || []; - translationsTags.push(...tags); + function getLocale(doc: CMSDoc) { + const docLocales = doc.sys?.locales || ['en']; + let locale = ctx.route.locale; + if (ctx.route.isDefaultLocale) { + locale = ctx.getPreferredLocale(docLocales); + if (docLocales.length > 0 && !docLocales.includes(locale)) { + locale = docLocales[0]; + } + } + return locale; } - const [doc, translationsMap, data] = await Promise.all([ - cmsClient.getDoc(options.collection, slug, { - mode, - }), - cmsClient.loadTranslations({tags: translationsTags}), - fetchData(routeContext), - ]); - if (!doc) { + const resData = await generateProps(routeContext, getLocale); + if (resData.notFound) { res.setHeader('cache-control', 'private'); return ctx.render404(); } - const sys = doc.sys; - const docLocales = sys.locales || ['en']; - let locale = ctx.route.locale; - if (ctx.route.isDefaultLocale) { - locale = ctx.getPreferredLocale(docLocales); - if (docLocales.length > 0 && !docLocales.includes(locale)) { - locale = docLocales[0]; - } - } - const country = - getFirstQueryParam(req, 'gl') || - req.get('x-country-code') || - req.get('x-appengine-country') || - null; - const translations = translationsForLocale(translationsMap, locale); - let props: any = {...data, req, locale, mode, slug, doc, country}; - if (options.preRenderHook) { - props = await options.preRenderHook(props, routeContext); - } + const {props, locale, translations} = resData; if (props.$redirect) { const redirectCode = props.$redirectCode || 302; diff --git a/packages/root-cms/core/compatibility.ts b/packages/root-cms/core/compatibility.ts new file mode 100644 index 00000000..a6f7e6bb --- /dev/null +++ b/packages/root-cms/core/compatibility.ts @@ -0,0 +1,110 @@ +import {RootConfig} from '@blinkk/root'; +import {Timestamp} from 'firebase-admin/firestore'; +import {RootCMSClient, Translation, TranslationsDoc} from './client.js'; + +/** + * Runs compatibility checks to ensure the current version of root supports + * any backwards-incompatible db changes. + * + * Compatibility versions are stored in the db in Projects/ under + * the "compatibility" key. If the compatibility version is less than the + * latest version, a backwards-friendly "migration" is done to ensure + * compatibility on both the old and new versions. + */ +export async function runCompatibilityChecks(rootConfig: RootConfig) { + const cmsClient = new RootCMSClient(rootConfig); + const projectId = cmsClient.projectId; + const db = cmsClient.db; + const projectConfigDocRef = db.doc(`Projects/${projectId}`); + const projectConfigDoc = await projectConfigDocRef.get(); + const projectConfig = projectConfigDoc.data() || {}; + + const compatibilityVersions = projectConfig.compatibility || {}; + let versionsChanged = false; + + // const translationsVersion = compatibilityVersions.translations || 0; + const translationsVersion = 1; + if (translationsVersion < 2) { + await migrateTranslationsToV2(rootConfig, cmsClient); + compatibilityVersions.translations = 2; + versionsChanged = true; + } + + if (versionsChanged) { + await projectConfigDocRef.update({compatibility: compatibilityVersions}); + console.log('[root cms] updated db compatibility'); + } +} + +/** + * Migrates translations from the "v1" format to "v2". + */ +async function migrateTranslationsToV2( + rootConfig: RootConfig, + cmsClient: RootCMSClient +) { + if (rootConfig.experiments?.rootCmsDisableTranslationsToV2Check) { + return; + } + + const projectId = cmsClient.projectId; + const db = cmsClient.db; + const dbPath = `Projects/${projectId}/Translations`; + const query = db.collection(dbPath); + const querySnapshot = await query.get(); + if (querySnapshot.size === 0) { + return; + } + + console.log('[root cms] updating translations v2 compatibility'); + + const translationsDocs: Record = {}; + querySnapshot.forEach((doc) => { + const translation = doc.data(); + const source = cmsClient.normalizeString(translation.source); + delete translation.source; + const tags = (translation.tags || []) as string[]; + delete translation.tags; + for (const tag of tags) { + if (tag.includes('/')) { + const translationsId = tag; + translationsDocs[translationsId] ??= { + id: translationsId, + tags: tags, + strings: {}, + }; + translationsDocs[translationsId].strings[source] = translation; + } + } + }); + + if (Object.keys(translationsDocs).length === 0) { + console.log('[root cms] no translations to save'); + return; + } + + // Move the doc's "l10nSheet" to the translations doc's "linkedSheet". + // for (const docId in translationsDocs) { + // const [collection, slug] = docId.split('/'); + // if (collection && slug) { + // const doc: any = await cmsClient.getDoc(collection, slug, { + // mode: 'draft', + // }); + // const linkedSheet = doc?.sys?.l10nSheet; + // if (linkedSheet) { + // translationsDocs[docId].sys.linkedSheet = linkedSheet; + // } + // } + // } + + const tm = cmsClient.getTranslationsManager(); + Object.entries(translationsDocs).forEach(([translationsId, data]) => { + const len = Object.keys(data.strings).length; + console.log(`[root cms] saving ${len} string(s) to ${translationsId}...`); + tm.saveTranslations(translationsId, data.strings, { + tags: data.tags || [translationsId], + }); + }); + + console.log('[root cms] done migrating translations to v2'); +} diff --git a/packages/root-cms/core/core.ts b/packages/root-cms/core/core.ts index 2cceaa70..47c14e42 100644 --- a/packages/root-cms/core/core.ts +++ b/packages/root-cms/core/core.ts @@ -1,3 +1,4 @@ export * from './client.js'; +export * from './cms-route.js'; export * from './runtime.js'; export * as schema from './schema.js'; diff --git a/packages/root-cms/core/plugin.ts b/packages/root-cms/core/plugin.ts index 4fe55128..bdc31919 100644 --- a/packages/root-cms/core/plugin.ts +++ b/packages/root-cms/core/plugin.ts @@ -1,13 +1,13 @@ import {promises as fs} from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; - -import { +import type { ConfigureServerOptions, NextFunction, Plugin, Request, Response, + RootConfig, Server, } from '@blinkk/root'; import bodyParser from 'body-parser'; @@ -21,9 +21,9 @@ import {getAuth, DecodedIdToken} from 'firebase-admin/auth'; import {Firestore, getFirestore} from 'firebase-admin/firestore'; import * as jsonwebtoken from 'jsonwebtoken'; import sirv from 'sirv'; -import {generateTypes} from '../cli/generate-types.js'; import {api} from './api.js'; import {Action, RootCMSClient} from './client.js'; +import {runCompatibilityChecks} from './compatibility.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -439,6 +439,18 @@ export function cmsPlugin(options: CMSPluginOptions): CMSPlugin { cms: path.resolve(__dirname, './app.js'), }), + hooks: { + /** + * Startup hook. On `dev` and `build` commands, the plugin does + * compatibility checks. + */ + startup: async ({command, rootConfig}) => { + if (command === 'dev' || command === 'build') { + await runCompatibilityChecks(rootConfig); + } + }, + }, + /** * Attaches CMS-specific middleware to the Root.js server. */ @@ -607,3 +619,12 @@ function fileExists(filepath: string): Promise { .then(() => true) .catch(() => false); } + +export function getCmsPlugin(rootConfig: RootConfig): CMSPlugin { + const plugins: Plugin[] = rootConfig.plugins || []; + const plugin = plugins.find((plugin) => plugin.name === 'root-cms'); + if (!plugin) { + throw new Error('could not find root-cms plugin config in root.config.ts'); + } + return plugin as CMSPlugin; +} diff --git a/packages/root-cms/core/translations-manager.ts b/packages/root-cms/core/translations-manager.ts new file mode 100644 index 00000000..45e32c39 --- /dev/null +++ b/packages/root-cms/core/translations-manager.ts @@ -0,0 +1,425 @@ +import { + FieldValue, + Query, + Timestamp, + WriteBatch, +} from 'firebase-admin/firestore'; +import {hashStr} from '../shared/strings.js'; +import type {RootCMSClient} from './client.js'; + +export type Locale = string; +export type SourceString = string; +export type TranslatedString = string; +export type TranslationsDocMode = 'draft' | 'published'; + +/** + * The TranslationsLocaleDoc is the internal doc type stored in the DB. For a + * translations doc, the translations for each locale is stored in a separate + * doc represented by this type. + * + * This type is not meant to be used by external callers since this is primarily + * an internal implementation detail. + */ +interface TranslationsLocaleDoc { + id: string; + locale: string; + tags: string[]; + strings: TranslationsLocaleDocHashMap; + sys: { + modifiedAt: Timestamp; + modifiedBy: string; + publishedAt?: Timestamp; + publishedBy?: string; + linkedSheet?: { + spreadsheetId: string; + gid: number; + linkedAt: Timestamp; + linkedBy: string; + }; + }; +} + +export interface TranslationsLocaleDocHashMap { + /** + * A hash map of a source string's hash fingerprint to the source string and + * translated string. + */ + [hash: string]: TranslationsLocaleDocEntry; +} + +export interface TranslationsLocaleDocEntry { + source: SourceString; + translation: TranslatedString; + // TODO(stevenle): in the future we should add an ability for content editors + // to provide additional translations notes for translators. + // context: string; +} + +interface TranslationsDbPathOptions { + project: string; + mode: TranslationsDocMode; +} + +type TranslationsLocaleDocDbPathOptions = TranslationsDbPathOptions & { + id: string; + locale: string; +}; + +const TRANSLATIONS_DB_PATH_FORMAT = + '/Projects/{project}/TranslationsManager/{mode}/Translations'; + +const TRANSLATIONS_LOCALE_DOC_DB_PATH_FORMAT = `${TRANSLATIONS_DB_PATH_FORMAT}/{id}:{locale}`; + +/** + * A translations map containing translations for multiple locales. + * + * Example: + * ``` + * { + * "one": {"es": "uno", "fr": "un"}, + * "two": {"es": "dos", "fr": "deux"} + * } + * ``` + */ +export interface MultiLocaleTranslationsMap { + [source: SourceString]: { + [locale: Locale]: TranslatedString; + }; +} + +/** + * A translations map containing translations for a single locale. + * + * Example: + * ``` + * { + * "one": "uno", + * "two": "dos" + * } + * ``` + */ +export interface SingleLocaleTranslationsMap { + [source: SourceString]: TranslatedString; +} + +export class TranslationsManager { + cmsClient: RootCMSClient; + + constructor(cmsClient: RootCMSClient) { + this.cmsClient = cmsClient; + } + + /** + * Saves draft translations for a translations doc id. + * + * Example: + * ``` + * const strings = { + * 'one': {es: 'uno', fr: 'un'}, + * 'two': {es: 'dos', fr: 'deux'}, + * }; + * await tm.saveTranslations('Pages/index', strings); + * ``` + */ + async saveTranslations( + id: string, + strings: MultiLocaleTranslationsMap, + options?: {tags?: string[]; modifiedBy?: string} + ) { + const mode = 'draft'; + const localesSet: Set = new Set(); + Object.values(strings).forEach((entry) => { + Object.keys(entry).forEach((locale) => { + if (locale !== 'source') { + localesSet.add(locale); + } + }); + }); + + const batch = this.cmsClient.db.batch(); + const locales = Array.from(localesSet); + locales.forEach((locale) => { + const updates: Record = { + id: id, + locale: locale, + sys: { + modifiedAt: Timestamp.now(), + modifiedBy: options?.modifiedBy || 'root-cms-client', + }, + strings: {}, + }; + if (options?.tags && options.tags.length > 0) { + updates.tags = FieldValue.arrayUnion(...options.tags); + } + let numUpdates = 0; + const hashMap = this.toLocaleDocHashMap(strings, locale); + Object.entries(hashMap).forEach(([hash, translations]) => { + Object.entries(translations).forEach(([locale, translation]) => { + if (translation) { + updates.strings[hash] ??= {}; + updates.strings[hash][locale] = translation; + numUpdates += 1; + } + }); + }); + if (numUpdates > 0) { + const localeDocPath = getTranslationsLocaleDocDbPath({ + project: this.cmsClient.projectId, + mode: mode, + id: id, + locale: locale, + }); + const localeDocRef = this.cmsClient.db.doc(localeDocPath); + batch.set(localeDocRef, updates, {merge: true}); + } + }); + await batch.commit(); + } + + /** + * Publishes a translations doc id. + */ + async publishTranslations( + id: string, + options?: {batch?: WriteBatch; publishedBy?: string} + ) { + const db = this.cmsClient.db; + const project = this.cmsClient.projectId; + const draftPath = getTranslationsDbPath({project, mode: 'draft'}); + const query = db.collection(draftPath).where('id', '==', id); + const res = await query.get(); + if (res.size === 0) { + console.warn(`no translations to publish for ${id}`); + return; + } + + const batch = options?.batch || db.batch(); + res.docs.forEach((doc) => { + const translationsLocaleDoc = doc.data() as TranslationsLocaleDoc; + const sys = { + ...translationsLocaleDoc.sys, + publishedAt: Timestamp.now(), + publishedBy: options?.publishedBy || 'root-cms-client', + }; + batch.update(doc.ref, {sys}); + const publishedDocPath = getTranslationsLocaleDocDbPath({ + project, + mode: 'published', + id: translationsLocaleDoc.id, + locale: translationsLocaleDoc.locale, + }); + const publishedDocRef = db.doc(publishedDocPath); + batch.set(publishedDocRef, {...translationsLocaleDoc, sys}); + }); + + // If a batch was provided, assume that the caller is responsible for + // calling `batch.commit()`. + const shouldCommitBatch = !options?.batch; + if (shouldCommitBatch) { + await batch.commit(); + } + } + + /** + * Fetches translations from one or more translations docs in the translations + * manager. + * + * Example: + * ``` + * await tm.loadTranslations(); + * // => + * // { + * // "one": {"es": "uno", "fr": "un"}, + * // "two": {"es": "dos", "fr": "deux"} + * // } + * ``` + * + * To load a specific set of translations docs by id: + * ``` + * const translationsToLoad = ['Global/strings', 'Global/header', 'Global/footer', 'Pages/index']; + * await tm.loadTranslations({ids: translationsToLoad}); + * // => + * // { + * // "one": {"es": "uno", "fr": "un"}, + * // "two": {"es": "dos", "fr": "deux"} + * // } + * ``` + * + * To load a subset of locales (more performant): + * ``` + * await tm.loadTranslations({locales: ['es']}); + * // => + * // { + * // "one": {"es": "uno"}, + * // "two": {"es": "dos"} + * // } + * ``` + */ + async loadTranslations(options?: { + ids?: string[]; + tags?: string[]; + locales?: Locale[]; + mode?: TranslationsDocMode; + }) { + const mode = options?.mode || 'published'; + const dbPath = getTranslationsDbPath({ + project: this.cmsClient.projectId, + mode: mode, + }); + + let query = this.cmsClient.db.collection(dbPath) as Query; + if (options?.ids && options.ids.length > 0) { + query = query.where('id', 'in', options.ids); + } + if (options?.tags && options.tags.length > 0) { + query = query.where('tags', 'array-contains', options.tags); + } + if (options?.locales && options.locales.length > 0) { + query = query.where('locale', 'in', options.locales); + } + + const results = await query.get(); + const strings: MultiLocaleTranslationsMap = {}; + results.forEach((result) => { + const localeDoc = result.data() as TranslationsLocaleDoc; + Object.values(localeDoc.strings || {}).forEach((item) => { + strings[item.source] ??= {source: item.source}; + strings[item.source][localeDoc.locale] = item.translation; + }); + }); + return strings; + } + + /** + * Fetches translations for a given locale, with optional fallbacks. + * The return value is a map of source string to translated string. + * + * Example: + * ``` + * await translationsDoc.loadTranslationsForLocale('es'); + * // => + * // { + * // "one": "uno", + * // "two": "dos", + * // } + * ``` + */ + async loadTranslationsForLocale( + locale: Locale, + options?: {mode?: TranslationsDocMode; fallbackLocales?: Locale[]} + ): Promise { + const localeSet: Set = new Set([ + locale, + ...(options?.fallbackLocales || []), + ]); + const fallbackLocales = Array.from(localeSet); + const multiLocaleStrings = await this.loadTranslations({ + mode: options?.mode, + locales: fallbackLocales, + }); + return this.toSingleLocaleMap(multiLocaleStrings, fallbackLocales); + } + + /** + * Converts a multi-locale translations map to a flat single-locale map, + * with optional support for fallback locales. + * + * ``` + * const multiLocaleStrings = { + * 'one': {es: 'uno', fr: 'un'}, + * 'two': {es: 'dos', fr: 'deux'} + * }; + * translationsDoc.toSingleLocaleMap(multiLocaleStrings, ['fr']); + * // => + * // { + * // "one": "uno", + * // "two": "dos", + * // } + * ``` + */ + private toSingleLocaleMap( + multiLocaleStrings: MultiLocaleTranslationsMap, + fallbackLocales: Locale[] + ): SingleLocaleTranslationsMap { + const singleLocaleStrings: SingleLocaleTranslationsMap = {}; + Object.entries(multiLocaleStrings).forEach(([source, translations]) => { + let translation = source; + for (const locale of fallbackLocales) { + if (translations[locale]) { + translation = translations[locale]; + break; + } + } + singleLocaleStrings[source] = translation; + }); + return singleLocaleStrings; + } + + /** + * Converts a multi-locale translations map to a single-locale hashed version, + * used for storage in in the DB. + * + * ``` + * const multiLocaleStrings = { + * 'one': {es: 'uno', fr: 'un'}, + * 'two': {es: 'dos', fr: 'deux'} + * }; + * translationsDoc.toLocaleDocHashMap(multiLocaleStrings, 'es'); + * // => + * // { + * // "": {"source": "one", "translation": "uno"}, + * // "": {"source": "two", "translation": "dos"}, + * // } + * ``` + * + * One reason for using hashes is because the DB has limits on the number of + * chars that can be used as the "key" in a object map. + */ + private toLocaleDocHashMap( + multiLocaleStrings: MultiLocaleTranslationsMap, + locale: Locale + ): TranslationsLocaleDocHashMap { + const hashMap: TranslationsLocaleDocHashMap = {}; + Object.entries(multiLocaleStrings).forEach(([source, translations]) => { + const translation = translations[locale]; + if (translation) { + const hash = hashStr(source); + hashMap[hash] = {source, translation}; + } + }); + return hashMap; + } + + /** + * Combines two locale doc hash maps. + */ + private mergeLocaleDocHashMaps( + a: TranslationsLocaleDocHashMap, + b: TranslationsLocaleDocHashMap + ): TranslationsLocaleDocHashMap { + const results: TranslationsLocaleDocHashMap = {...a}; + Object.entries(b).forEach(([hash, translations]) => { + results[hash] = {...results[hash], ...translations}; + }); + return results; + } +} + +function getTranslationsDbPath(options: TranslationsDbPathOptions) { + return TRANSLATIONS_DB_PATH_FORMAT.replace( + '{project}', + options.project + ).replace('{mode}', options.mode); +} + +function getTranslationsLocaleDocDbPath( + options: TranslationsLocaleDocDbPathOptions +) { + return TRANSLATIONS_LOCALE_DOC_DB_PATH_FORMAT.replace( + '{project}', + options.project + ) + .replace('{mode}', options.mode) + .replace('{id}', options.id.replaceAll('/', '--')) + .replace('{locale}', options.locale); +} diff --git a/packages/root-cms/core/tsconfig.json b/packages/root-cms/core/tsconfig.json index 428be5d8..fa63b44a 100644 --- a/packages/root-cms/core/tsconfig.json +++ b/packages/root-cms/core/tsconfig.json @@ -30,5 +30,6 @@ "**/*.ts", "*.tsx", "**/*.tsx", + "../shared/*.ts" ] } diff --git a/packages/root-cms/package.json b/packages/root-cms/package.json index 9dc9362e..66c93938 100644 --- a/packages/root-cms/package.json +++ b/packages/root-cms/package.json @@ -1,6 +1,6 @@ { "name": "@blinkk/root-cms", - "version": "1.3.12", + "version": "2.0.0-rc.1", "author": "s@blinkk.com", "license": "MIT", "engines": { @@ -72,6 +72,7 @@ "csv-parse": "5.5.2", "csv-stringify": "6.4.4", "dts-dom": "3.7.0", + "farmhash-modern": "1.1.0", "jsonwebtoken": "9.0.2", "kleur": "4.1.5", "sirv": "2.0.3", @@ -131,7 +132,7 @@ "vitest": "0.34.6" }, "peerDependencies": { - "@blinkk/root": "1.3.12", + "@blinkk/root": "2.0.0-rc.1", "firebase-admin": ">=11", "firebase-functions": ">=4", "preact": ">=10", diff --git a/packages/root-cms/shared/strings.ts b/packages/root-cms/shared/strings.ts new file mode 100644 index 00000000..071e95fe --- /dev/null +++ b/packages/root-cms/shared/strings.ts @@ -0,0 +1,35 @@ +/** + * Shared utility functions for handling strings. + */ + +import * as farmhash from 'farmhash-modern'; + +/** + * Cleans a source string for use in translations. Performs the following: + * - Removes any leading/trailing whitespace + * - Removes spaces at the end of any line (including  ) + */ +export function normalizeStr(str: string): string { + const lines = str + .trim() + .split('\n') + .map((line) => removeTrailingWhitespace(line)); + return lines.join('\n'); +} + +function removeTrailingWhitespace(str: string) { + return str.trimEnd().replace(/ $/, ''); +} + +/** + * Returns a hash fingerprint for a string. + * + * Note that this hash function is meant to be fast and for collision avoidance + * for use in a hash map, but is not intended for cryptographic purposes. For + * these reasons farmhash is used here. + * + * @see https://www.npmjs.com/package/farmhash-modern + */ +export function hashStr(str: string): string { + return String(farmhash.fingerprint32(normalizeStr(str))); +} diff --git a/packages/root-cms/ui/components/ActionLogs/ActionLogs.tsx b/packages/root-cms/ui/components/ActionLogs/ActionLogs.tsx index 4900b668..1bb84eb4 100644 --- a/packages/root-cms/ui/components/ActionLogs/ActionLogs.tsx +++ b/packages/root-cms/ui/components/ActionLogs/ActionLogs.tsx @@ -1,9 +1,10 @@ import {Button, Loader, Table, Tooltip} from '@mantine/core'; import {Timestamp} from 'firebase/firestore'; import {useEffect, useState} from 'preact/hooks'; -import {Action, listActions} from '../../utils/actions.js'; +import {Action, listActions} from '@/db/actions.js'; +import {getSpreadsheetUrl} from '@/utils/gsheets.js'; +import {stringifyObj} from '@/utils/objects.js'; import './ActionsLogs.css'; -import {getSpreadsheetUrl} from '../../utils/gsheets.js'; export interface ActionLogsProps { className?: string; @@ -145,29 +146,3 @@ function formatDate(timestamp: Timestamp) { timeStyle: 'medium', }); } - -/** A pretty printer for JavaScript objects. */ -function stringifyObj(obj: any) { - function format(obj: any): string { - if (obj === null) { - return 'null'; - } - if (typeof obj === 'undefined') { - return 'undefined'; - } - if (typeof obj === 'string') { - return `"${obj.replaceAll('"', '\\"')}"`; - } - if (typeof obj !== 'object') { - return String(obj); - } - if (Array.isArray(obj)) { - return `[${obj.map(format).join(', ')}]`; - } - const entries: string[] = Object.entries(obj).map(([key, value]) => { - return `${key}: ${format(value)}`; - }); - return `{${entries.join(', ')}}`; - } - return format(obj); -} diff --git a/packages/root-cms/ui/components/AssetUploader/AssetUploader.tsx b/packages/root-cms/ui/components/AssetUploader/AssetUploader.tsx index a7d8e26e..9b427ac9 100644 --- a/packages/root-cms/ui/components/AssetUploader/AssetUploader.tsx +++ b/packages/root-cms/ui/components/AssetUploader/AssetUploader.tsx @@ -2,9 +2,9 @@ import {TextInput} from '@mantine/core'; import {showNotification} from '@mantine/notifications'; import {IconFileUpload} from '@tabler/icons-preact'; import {useEffect, useRef, useState} from 'preact/hooks'; -import {Text} from '../../components/Text/Text.js'; -import {joinClassNames} from '../../utils/classes.js'; -import {UploadFileOptions, uploadFileToGCS} from '../../utils/gcs.js'; +import {Text} from '@/components/Text/Text.js'; +import {joinClassNames} from '@/utils/classes.js'; +import {UploadFileOptions, uploadFileToGCS} from '@/utils/gcs.js'; import './AssetUploader.css'; export const IMAGE_MIMETYPES = [ @@ -62,30 +62,29 @@ export function AssetUploader(props: AssetUploaderProps) { } } - const handleDragEnter = (e: DragEvent) => { - e.preventDefault(); - setIsDragging(true); - }; + useEffect(() => { + const dropzone = ref.current; + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; - const handleDragLeave = (e: DragEvent) => { - e.preventDefault(); - setIsDragging(false); - }; + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; - const handleDrop = (e: DragEvent) => { - e.preventDefault(); - setIsDragging(false); + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); - const files = e.dataTransfer?.files || []; - const file = files[0]; - if (file) { - console.log('file dropped:', file); - uploadFile(file); - } - }; - - useEffect(() => { - const dropzone = ref.current; + const files = e.dataTransfer?.files || []; + const file = files[0]; + if (file) { + console.log('file dropped:', file); + uploadFile(file); + } + }; document.addEventListener('dragenter', handleDragEnter); document.addEventListener('dragover', handleDragEnter); document.addEventListener('dragleave', handleDragLeave); @@ -160,7 +159,6 @@ AssetUploader.FilePreview = (props: {asset: any}) => { AssetUploader.ImagePreview = (props: {asset: any}) => { const asset = props.asset; - console.log(asset); return (
diff --git a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx index d96aead6..b3a903fc 100644 --- a/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx +++ b/packages/root-cms/ui/components/CopyDocModal/CopyDocModal.tsx @@ -3,11 +3,12 @@ import {ContextModalProps, useModals} from '@mantine/modals'; import {showNotification} from '@mantine/notifications'; import {useState} from 'preact/hooks'; import {route} from 'preact-router'; -import {useModalTheme} from '../../hooks/useModalTheme.js'; -import {cmsCopyDoc} from '../../utils/doc.js'; -import {isSlugValid, normalizeSlug} from '../../utils/slug.js'; -import {SlugInput} from '../SlugInput/SlugInput.js'; -import {Text} from '../Text/Text.js'; +import {SlugInput} from '@/components/SlugInput/SlugInput.js'; +import {Text} from '@/components/Text/Text.js'; +import {dbCopyDoc} from '@/db/docs.js'; +import {useModalTheme} from '@/hooks/useModalTheme.js'; +import {isSlugValid, normalizeSlug} from '@/utils/slug.js'; + import './CopyDocModal.css'; const MODAL_ID = 'CopyDocModal'; @@ -60,7 +61,7 @@ export function CopyDocModal(modalProps: ContextModalProps) { const toDocId = `${toCollectionId}/${cleanSlug}`; try { - await cmsCopyDoc(fromDocId, toDocId, {overwrite: confirmOverwrite}); + await dbCopyDoc(fromDocId, toDocId, {overwrite: confirmOverwrite}); context.closeModal(id); showNotification({ title: 'Copied!', diff --git a/packages/root-cms/ui/components/DataSourceForm/DataSourceForm.tsx b/packages/root-cms/ui/components/DataSourceForm/DataSourceForm.tsx index e63964e8..72c35dc6 100644 --- a/packages/root-cms/ui/components/DataSourceForm/DataSourceForm.tsx +++ b/packages/root-cms/ui/components/DataSourceForm/DataSourceForm.tsx @@ -8,7 +8,6 @@ import { import {showNotification} from '@mantine/notifications'; import {useEffect, useRef, useState} from 'preact/hooks'; import {route} from 'preact-router'; -import {useGapiClient} from '../../hooks/useGapiClient.js'; import { DataSource, DataSourceType, @@ -17,10 +16,11 @@ import { addDataSource, getDataSource, updateDataSource, -} from '../../utils/data-source.js'; -import {parseSpreadsheetUrl} from '../../utils/gsheets.js'; -import {notifyErrors} from '../../utils/notifications.js'; -import {isSlugValid} from '../../utils/slug.js'; +} from '@/db/data-sources.js'; +import {useGapiClient} from '@/hooks/useGapiClient.js'; +import {parseSpreadsheetUrl} from '@/utils/gsheets.js'; +import {notifyErrors} from '@/utils/notifications.js'; +import {isSlugValid} from '@/utils/slug.js'; import './DataSourceForm.css'; const HTTP_URL_HELP = 'Enter the URL to make the HTTP request.'; diff --git a/packages/root-cms/ui/components/DataSourceStatusButton/DataSourceStatusButton.tsx b/packages/root-cms/ui/components/DataSourceStatusButton/DataSourceStatusButton.tsx index 4f347137..369d9928 100644 --- a/packages/root-cms/ui/components/DataSourceStatusButton/DataSourceStatusButton.tsx +++ b/packages/root-cms/ui/components/DataSourceStatusButton/DataSourceStatusButton.tsx @@ -2,13 +2,13 @@ import {Button, Tooltip} from '@mantine/core'; import {showNotification, updateNotification} from '@mantine/notifications'; import {Timestamp} from 'firebase/firestore'; import {useState} from 'preact/hooks'; -import {useGapiClient} from '../../hooks/useGapiClient.js'; +import {TimeSinceActionTooltip} from '@/components/TimeSinceActionTooltip/TimeSinceActionTooltip.js'; import { DataSource, publishDataSource, syncDataSource, -} from '../../utils/data-source.js'; -import {TimeSinceActionTooltip} from '../TimeSinceActionTooltip/TimeSinceActionTooltip.js'; +} from '@/db/data-sources.js'; +import {useGapiClient} from '@/hooks/useGapiClient.js'; import './DataSourceStatusButton.css'; export interface DataSourceStatusButtonProps { diff --git a/packages/root-cms/ui/components/DocActionsMenu/DocActionsMenu.tsx b/packages/root-cms/ui/components/DocActionsMenu/DocActionsMenu.tsx index c9487df2..5ab96b71 100644 --- a/packages/root-cms/ui/components/DocActionsMenu/DocActionsMenu.tsx +++ b/packages/root-cms/ui/components/DocActionsMenu/DocActionsMenu.tsx @@ -12,20 +12,20 @@ import { IconLockOpen, IconTrash, } from '@tabler/icons-preact'; -import {useModalTheme} from '../../hooks/useModalTheme.js'; +import {useCopyDocModal} from '@/components/CopyDocModal/CopyDocModal.js'; +import {useLockPublishingModal} from '@/components/LockPublishingModal/LockPublishingModal.js'; +import {Text} from '@/components/Text/Text.js'; +import {useVersionHistoryModal} from '@/components/VersionHistoryModal/VersionHistoryModal.js'; import { CMSDoc, - cmsDeleteDoc, - cmsRevertDraft, - cmsUnpublishDoc, - cmsUnscheduleDoc, + dbDeleteDoc, + dbRevertDraft, + dbUnpublishDoc, + dbUnscheduleDoc, testIsScheduled, testPublishingLocked, -} from '../../utils/doc.js'; -import {useCopyDocModal} from '../CopyDocModal/CopyDocModal.js'; -import {useLockPublishingModal} from '../LockPublishingModal/LockPublishingModal.js'; -import {Text} from '../Text/Text.js'; -import {useVersionHistoryModal} from '../VersionHistoryModal/VersionHistoryModal.js'; +} from '@/db/docs.js'; +import {useModalTheme} from '@/hooks/useModalTheme.js'; export interface DocActionEvent { action: 'copy' | 'delete' | 'revert-draft' | 'unpublish' | 'unschedule'; @@ -74,7 +74,7 @@ export function DocActionsMenu(props: DocActionsMenuProps) { loading: true, autoClose: false, }); - await cmsRevertDraft(docId); + await dbRevertDraft(docId); updateNotification({ id: notificationId, title: 'Discarded draft edited', @@ -116,7 +116,7 @@ export function DocActionsMenu(props: DocActionsMenuProps) { loading: true, autoClose: false, }); - await cmsUnpublishDoc(docId); + await dbUnpublishDoc(docId); updateNotification({ id: notificationId, title: 'Unpublished!', @@ -157,7 +157,7 @@ export function DocActionsMenu(props: DocActionsMenuProps) { loading: true, autoClose: false, }); - await cmsUnscheduleDoc(docId); + await dbUnscheduleDoc(docId); updateNotification({ id: notificationId, title: 'Unscheduled!', @@ -199,7 +199,7 @@ export function DocActionsMenu(props: DocActionsMenuProps) { loading: true, autoClose: false, }); - await cmsDeleteDoc(docId); + await dbDeleteDoc(docId); updateNotification({ id: notificationId, title: 'Deleted!', @@ -238,7 +238,7 @@ export function DocActionsMenu(props: DocActionsMenuProps) { icon={} onClick={() => copyDocModal.open()} > - Copy + Copy to... {sys.modifiedAt && sys.publishedAt && diff --git a/packages/root-cms/ui/components/DocDiffViewer/DocDiffViewer.tsx b/packages/root-cms/ui/components/DocDiffViewer/DocDiffViewer.tsx index dd3de927..235eced3 100644 --- a/packages/root-cms/ui/components/DocDiffViewer/DocDiffViewer.tsx +++ b/packages/root-cms/ui/components/DocDiffViewer/DocDiffViewer.tsx @@ -1,13 +1,13 @@ import {Button, Loader} from '@mantine/core'; import {Differ, Viewer as JsonDiffViewer} from 'json-diff-kit'; import {useEffect, useState} from 'preact/hooks'; -import {CMSDoc, cmsReadDocVersion, unmarshalData} from '../../utils/doc.js'; - -import 'json-diff-kit/dist/viewer.css'; +import {CMSDoc, unmarshalData} from '@/db/docs.js'; +import {dbGetDocVersion} from '@/db/versions.js'; +import {joinClassNames} from '@/utils/classes.js'; +import {getTimeAgo} from '@/utils/time.js'; import 'json-diff-kit/dist/viewer-monokai.css'; +import 'json-diff-kit/dist/viewer.css'; import './DocDiffViewer.css'; -import {getTimeAgo} from '../../utils/time.js'; -import {joinClassNames} from '../../utils/classes.js'; export interface DocVersionId { /** Doc id, e.g. `Pages/foo`. */ @@ -44,8 +44,8 @@ export function DocDiffViewer(props: DocDiffViewerProps) { async function init() { setLoading(true); const [leftDoc, rightDoc] = await Promise.all([ - cmsReadDocVersion(left.docId, left.versionId), - cmsReadDocVersion(right.docId, right.versionId), + dbGetDocVersion(left.docId, left.versionId), + dbGetDocVersion(right.docId, right.versionId), ]); setLeftDoc(leftDoc); setRightDoc(rightDoc); diff --git a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx index 09ddce14..250bdf40 100644 --- a/packages/root-cms/ui/components/DocEditor/DocEditor.tsx +++ b/packages/root-cms/ui/components/DocEditor/DocEditor.tsx @@ -23,6 +23,7 @@ import { IconTrash, IconTriangleFilled, } from '@tabler/icons-preact'; +import {createContext} from 'preact'; import { useContext, useEffect, @@ -32,35 +33,25 @@ import { useState, } from 'preact/hooks'; import {route} from 'preact-router'; - -import * as schema from '../../../core/schema.js'; -import { - DraftController, - SaveState, - UseDraftHook, -} from '../../hooks/useDraft.js'; -import {joinClassNames} from '../../utils/classes.js'; -import { - CMSDoc, - testIsScheduled, - testPublishingLocked, -} from '../../utils/doc.js'; -import {extractField} from '../../utils/extract.js'; -import {getDefaultFieldValue} from '../../utils/fields.js'; -import {flattenNestedKeys} from '../../utils/objects.js'; -import {autokey} from '../../utils/rand.js'; -import {getPlaceholderKeys, strFormat} from '../../utils/str-format.js'; -import {formatDateTime} from '../../utils/time.js'; import { DocActionEvent, DocActionsMenu, -} from '../DocActionsMenu/DocActionsMenu.js'; -import {DocStatusBadges} from '../DocStatusBadges/DocStatusBadges.js'; -import {useEditJsonModal} from '../EditJsonModal/EditJsonModal.js'; -import {useLocalizationModal} from '../LocalizationModal/LocalizationModal.js'; -import {usePublishDocModal} from '../PublishDocModal/PublishDocModal.js'; -import './DocEditor.css'; -import {Viewers} from '../Viewers/Viewers.js'; +} from '@/components/DocActionsMenu/DocActionsMenu.js'; +import {DocStatusBadges} from '@/components/DocStatusBadges/DocStatusBadges.js'; +import {useEditJsonModal} from '@/components/EditJsonModal/EditJsonModal.js'; +import {useEditTranslationsModal} from '@/components/EditTranslationsModal/EditTranslationsModal.js'; +import {useLocalizationModal} from '@/components/LocalizationModal/LocalizationModal.js'; +import {usePublishDocModal} from '@/components/PublishDocModal/PublishDocModal.js'; +import {Viewers} from '@/components/Viewers/Viewers.js'; +import {CMSDoc, testIsScheduled, testPublishingLocked} from '@/db/docs.js'; +import {DraftController, SaveState, UseDraftHook} from '@/hooks/useDraft.js'; +import {joinClassNames} from '@/utils/classes.js'; +import {extractField} from '@/utils/extract.js'; +import {getDefaultFieldValue} from '@/utils/fields.js'; +import {flattenNestedKeys} from '@/utils/objects.js'; +import {autokey} from '@/utils/rand.js'; +import {getPlaceholderKeys, strFormat} from '@/utils/str-format.js'; +import {formatDateTime} from '@/utils/time.js'; import {BooleanField} from './fields/BooleanField.js'; import {DateTimeField} from './fields/DateTimeField.js'; import {FieldProps} from './fields/FieldProps.js'; @@ -71,8 +62,8 @@ import {ReferenceField} from './fields/ReferenceField.js'; import {RichTextField} from './fields/RichTextField.js'; import {SelectField} from './fields/SelectField.js'; import {StringField} from './fields/StringField.js'; -import {createContext} from 'preact'; -import {useEditTranslationsModal} from '../EditTranslationsModal/EditTranslationsModal.js'; +import * as schema from '@/../core/schema.js'; +import './DocEditor.css'; interface DocEditorProps { docId: string; diff --git a/packages/root-cms/ui/components/DocEditor/fields/BooleanField.tsx b/packages/root-cms/ui/components/DocEditor/fields/BooleanField.tsx index b99e57ca..7f351de5 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/BooleanField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/BooleanField.tsx @@ -1,7 +1,7 @@ import {Checkbox} from '@mantine/core'; import {useEffect, useState} from 'preact/hooks'; -import * as schema from '../../../../core/schema.js'; import {FieldProps} from './FieldProps.js'; +import * as schema from '@/../core/schema.js'; export function BooleanField(props: FieldProps) { const field = props.field as schema.BooleanField; diff --git a/packages/root-cms/ui/components/DocEditor/fields/FieldProps.ts b/packages/root-cms/ui/components/DocEditor/fields/FieldProps.ts index 9426ec1b..18f4ad18 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/FieldProps.ts +++ b/packages/root-cms/ui/components/DocEditor/fields/FieldProps.ts @@ -1,5 +1,5 @@ -import * as schema from '../../../../core/schema.js'; -import {DraftController} from '../../../hooks/useDraft.js'; +import {DraftController} from '@/hooks/useDraft.js'; +import * as schema from '@/../core/schema.js'; export interface FieldProps { collection: schema.Collection; diff --git a/packages/root-cms/ui/components/DocEditor/fields/FileField.tsx b/packages/root-cms/ui/components/DocEditor/fields/FileField.tsx index 42b5ea31..78360dc9 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/FileField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/FileField.tsx @@ -2,10 +2,10 @@ import {ActionIcon, TextInput, Tooltip} from '@mantine/core'; import {showNotification} from '@mantine/notifications'; import {IconFileUpload, IconTrash} from '@tabler/icons-preact'; import {useEffect, useRef, useState} from 'preact/hooks'; -import * as schema from '../../../../core/schema.js'; -import {joinClassNames} from '../../../utils/classes.js'; -import {VIDEO_EXTS, getFileExt, uploadFileToGCS} from '../../../utils/gcs.js'; +import {joinClassNames} from '@/utils/classes.js'; +import {VIDEO_EXTS, getFileExt, uploadFileToGCS} from '@/utils/gcs.js'; import {FieldProps} from './FieldProps.js'; +import * as schema from '@/../core/schema.js'; export function FileField(props: FieldProps) { const field = props.field as schema.FileField; diff --git a/packages/root-cms/ui/components/DocEditor/fields/ImageField.tsx b/packages/root-cms/ui/components/DocEditor/fields/ImageField.tsx index ea48c530..d657af4f 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/ImageField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/ImageField.tsx @@ -3,10 +3,10 @@ import {showNotification} from '@mantine/notifications'; import {IconPhotoUp, IconTrash} from '@tabler/icons-preact'; import {ChangeEvent} from 'preact/compat'; import {useEffect, useRef, useState} from 'preact/hooks'; -import * as schema from '../../../../core/schema.js'; -import {joinClassNames} from '../../../utils/classes.js'; -import {uploadFileToGCS} from '../../../utils/gcs.js'; +import {joinClassNames} from '@/utils/classes.js'; +import {uploadFileToGCS} from '@/utils/gcs.js'; import {FieldProps} from './FieldProps.js'; +import * as schema from '@/../core/schema.js'; export const IMAGE_MIMETYPES = [ 'image/png', diff --git a/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.tsx b/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.tsx index 026d4349..135ba83b 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/MultiSelectField.tsx @@ -1,7 +1,7 @@ import {MultiSelect} from '@mantine/core'; import {useEffect, useState} from 'preact/hooks'; -import * as schema from '../../../../core/schema.js'; import {FieldProps} from './FieldProps.js'; +import * as schema from '@/../core/schema.js'; export function MultiSelectField(props: FieldProps) { const field = props.field as schema.MultiSelectField; diff --git a/packages/root-cms/ui/components/DocEditor/fields/ReferenceField.tsx b/packages/root-cms/ui/components/DocEditor/fields/ReferenceField.tsx index 05772bc7..e41d21d9 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/ReferenceField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/ReferenceField.tsx @@ -1,12 +1,12 @@ import {ActionIcon, Button, Image, Loader, Tooltip} from '@mantine/core'; import {IconTrash} from '@tabler/icons-preact'; import {useEffect, useState} from 'preact/hooks'; -import * as schema from '../../../../core/schema.js'; -import {getDocFromCacheOrFetch} from '../../../utils/doc-cache.js'; -import {notifyErrors} from '../../../utils/notifications.js'; -import {getNestedValue} from '../../../utils/objects.js'; -import {useDocPickerModal} from '../../DocPickerModal/DocPickerModal.js'; +import {useDocPickerModal} from '@/components/DocPickerModal/DocPickerModal.js'; +import {getDocFromCacheOrFetch} from '@/utils/doc-cache.js'; +import {notifyErrors} from '@/utils/notifications.js'; +import {getNestedValue} from '@/utils/objects.js'; import {FieldProps} from './FieldProps.js'; +import * as schema from '@/../core/schema.js'; import './ReferenceField.css'; export function ReferenceField(props: FieldProps) { diff --git a/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx b/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx index 44dc6180..4d623468 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx @@ -1,11 +1,11 @@ import {useEffect, useState} from 'preact/hooks'; -import * as schema from '../../../../core/schema.js'; -import {deepEqual} from '../../../utils/objects.js'; import { RichTextData, RichTextEditor, -} from '../../RichTextEditor/RichTextEditor.js'; +} from '@/components/RichTextEditor/RichTextEditor.js'; +import {deepEqual} from '@/utils/objects.js'; import {FieldProps} from './FieldProps.js'; +import * as schema from '@/../core/schema.js'; export function RichTextField(props: FieldProps) { const field = props.field as schema.RichTextField; diff --git a/packages/root-cms/ui/components/DocEditor/fields/SelectField.tsx b/packages/root-cms/ui/components/DocEditor/fields/SelectField.tsx index 327c6d66..1ae76559 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/SelectField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/SelectField.tsx @@ -1,7 +1,7 @@ import {Select} from '@mantine/core'; import {useEffect, useState} from 'preact/hooks'; -import * as schema from '../../../../core/schema.js'; import {FieldProps} from './FieldProps.js'; +import * as schema from '@/../core/schema.js'; export function SelectField(props: FieldProps) { const field = props.field as schema.SelectField; diff --git a/packages/root-cms/ui/components/DocEditor/fields/StringField.tsx b/packages/root-cms/ui/components/DocEditor/fields/StringField.tsx index b8eb889c..21029732 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/StringField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/StringField.tsx @@ -1,8 +1,8 @@ import {TextInput, Textarea} from '@mantine/core'; import {ChangeEvent} from 'preact/compat'; import {useEffect, useState} from 'preact/hooks'; -import * as schema from '../../../../core/schema.js'; import {FieldProps} from './FieldProps.js'; +import * as schema from '@/../core/schema.js'; export function StringField(props: FieldProps) { const field = props.field as schema.StringField; diff --git a/packages/root-cms/ui/components/DocPickerModal/DocPickerModal.tsx b/packages/root-cms/ui/components/DocPickerModal/DocPickerModal.tsx index 24d6ca55..125667b0 100644 --- a/packages/root-cms/ui/components/DocPickerModal/DocPickerModal.tsx +++ b/packages/root-cms/ui/components/DocPickerModal/DocPickerModal.tsx @@ -1,10 +1,10 @@ import {Button, Image, Loader, Select} from '@mantine/core'; import {ContextModalProps, useModals} from '@mantine/modals'; import {useState} from 'preact/hooks'; -import {useDocsList} from '../../hooks/useDocsList.js'; -import {useModalTheme} from '../../hooks/useModalTheme.js'; -import {getDocServingUrl} from '../../utils/doc-urls.js'; -import {getNestedValue} from '../../utils/objects.js'; +import {useDocsList} from '@/hooks/useDocsList.js'; +import {useModalTheme} from '@/hooks/useModalTheme.js'; +import {getDocServingUrl} from '@/utils/doc-urls.js'; +import {getNestedValue} from '@/utils/objects.js'; import './DocPickerModal.css'; const MODAL_ID = 'DocPickerModal'; diff --git a/packages/root-cms/ui/components/DocPreviewCard/DocPreviewCard.tsx b/packages/root-cms/ui/components/DocPreviewCard/DocPreviewCard.tsx index 984c217d..c496b704 100644 --- a/packages/root-cms/ui/components/DocPreviewCard/DocPreviewCard.tsx +++ b/packages/root-cms/ui/components/DocPreviewCard/DocPreviewCard.tsx @@ -1,11 +1,11 @@ import {Image, Loader} from '@mantine/core'; import {useEffect, useState} from 'preact/hooks'; -import {joinClassNames} from '../../utils/classes.js'; -import {getDocFromCacheOrFetch} from '../../utils/doc-cache.js'; -import {getDocServingUrl} from '../../utils/doc-urls.js'; -import {notifyErrors} from '../../utils/notifications.js'; -import {getNestedValue} from '../../utils/objects.js'; -import {DocStatusBadges} from '../DocStatusBadges/DocStatusBadges.js'; +import {DocStatusBadges} from '@/components/DocStatusBadges/DocStatusBadges.js'; +import {joinClassNames} from '@/utils/classes.js'; +import {getDocFromCacheOrFetch} from '@/utils/doc-cache.js'; +import {getDocServingUrl} from '@/utils/doc-urls.js'; +import {notifyErrors} from '@/utils/notifications.js'; +import {getNestedValue} from '@/utils/objects.js'; import './DocPreviewCard.css'; export interface DocPreviewCardProps { diff --git a/packages/root-cms/ui/components/DocSelectModal/DocSelectModal.tsx b/packages/root-cms/ui/components/DocSelectModal/DocSelectModal.tsx index 3b5725f3..add09ead 100644 --- a/packages/root-cms/ui/components/DocSelectModal/DocSelectModal.tsx +++ b/packages/root-cms/ui/components/DocSelectModal/DocSelectModal.tsx @@ -1,10 +1,10 @@ import {Button, Image, Loader, Select} from '@mantine/core'; import {ContextModalProps, useModals} from '@mantine/modals'; import {useState} from 'preact/hooks'; -import {useDocsList} from '../../hooks/useDocsList.js'; -import {useModalTheme} from '../../hooks/useModalTheme.js'; -import {getDocServingUrl} from '../../utils/doc-urls.js'; -import {getNestedValue} from '../../utils/objects.js'; +import {useDocsList} from '@/hooks/useDocsList.js'; +import {useModalTheme} from '@/hooks/useModalTheme.js'; +import {getDocServingUrl} from '@/utils/doc-urls.js'; +import {getNestedValue} from '@/utils/objects.js'; import './DocSelectModal.css'; const MODAL_ID = 'DocSelectModal'; diff --git a/packages/root-cms/ui/components/DocStatusBadges/DocStatusBadges.tsx b/packages/root-cms/ui/components/DocStatusBadges/DocStatusBadges.tsx index b5edffbf..1b4a4155 100644 --- a/packages/root-cms/ui/components/DocStatusBadges/DocStatusBadges.tsx +++ b/packages/root-cms/ui/components/DocStatusBadges/DocStatusBadges.tsx @@ -1,7 +1,7 @@ import {Badge, Tooltip} from '@mantine/core'; import {Timestamp} from 'firebase/firestore'; -import {CMSDoc, testPublishingLocked} from '../../utils/doc.js'; -import {formatDateTime, getTimeAgo} from '../../utils/time.js'; +import {CMSDoc, testPublishingLocked} from '@/db/docs.js'; +import {formatDateTime, getTimeAgo} from '@/utils/time.js'; interface DocStatusBadgesProps { doc: CMSDoc; diff --git a/packages/root-cms/ui/components/EditJsonModal/EditJsonModal.tsx b/packages/root-cms/ui/components/EditJsonModal/EditJsonModal.tsx index 49e7915e..83d099e4 100644 --- a/packages/root-cms/ui/components/EditJsonModal/EditJsonModal.tsx +++ b/packages/root-cms/ui/components/EditJsonModal/EditJsonModal.tsx @@ -2,7 +2,7 @@ import {Button, JsonInput} from '@mantine/core'; import {ContextModalProps, useModals} from '@mantine/modals'; import {IconClipboard, IconDeviceFloppy} from '@tabler/icons-preact'; import {useState} from 'preact/hooks'; -import {useModalTheme} from '../../hooks/useModalTheme.js'; +import {useModalTheme} from '@/hooks/useModalTheme.js'; import './EditJsonModal.css'; const MODAL_ID = 'EditJsonModal'; diff --git a/packages/root-cms/ui/components/EditTranslationsModal/EditTranslationsModal.tsx b/packages/root-cms/ui/components/EditTranslationsModal/EditTranslationsModal.tsx index 94174cad..e58aed98 100644 --- a/packages/root-cms/ui/components/EditTranslationsModal/EditTranslationsModal.tsx +++ b/packages/root-cms/ui/components/EditTranslationsModal/EditTranslationsModal.tsx @@ -3,13 +3,12 @@ import {ContextModalProps, useModals} from '@mantine/modals'; import {showNotification, updateNotification} from '@mantine/notifications'; import {ChangeEvent} from 'preact/compat'; import {useEffect, useState} from 'preact/hooks'; -import {useModalTheme} from '../../hooks/useModalTheme.js'; -import {joinClassNames} from '../../utils/classes.js'; -import {CsvTranslation, cmsDocImportTranslations} from '../../utils/doc.js'; -import {GoogleSheetId, getSpreadsheetUrl} from '../../utils/gsheets.js'; -import {loadTranslations} from '../../utils/l10n.js'; -import {notifyErrors} from '../../utils/notifications.js'; -import {Heading} from '../Heading/Heading.js'; +import {Heading} from '@/components/Heading/Heading.js'; +import {useModalTheme} from '@/hooks/useModalTheme.js'; +import {useTranslationsDoc} from '@/hooks/useTranslationsDoc.js'; +import {joinClassNames} from '@/utils/classes.js'; +import {GoogleSheetId, getSpreadsheetUrl} from '@/utils/gsheets.js'; +import {notifyErrors} from '@/utils/notifications.js'; import './EditTranslationsModal.css'; const MODAL_ID = 'EditTranslationsModal'; @@ -54,21 +53,22 @@ export function EditTranslationsModal( const [translationsMap, setTranslationsMap] = useState< Record> >({}); - const [changedKeys, setChangedKeys] = useState([]); - const [hasChanges, setHasChanges] = useState(false); const [saving, setSaving] = useState(false); const [loading, setLoading] = useState(true); + const translationsDoc = useTranslationsDoc(props.docId); + useEffect(() => { - loadTranslations({tags: [props.docId]}).then((res) => { - const translationsMap: Record> = {}; - Object.values(res).forEach((row) => { - translationsMap[row.source] = row; - }); - setTranslationsMap(translationsMap); - setLoading(false); + if (translationsDoc.loading) { + return; + } + const translationsMap: Record> = {}; + Object.entries(translationsDoc.strings).forEach(([hash, row]) => { + translationsMap[row.source] = {...row, hash}; }); - }, [props.docId]); + setTranslationsMap(translationsMap); + setLoading(false); + }, [translationsDoc.loading]); async function onSave() { setSaving(true); @@ -83,12 +83,7 @@ export function EditTranslationsModal( autoClose: false, disallowClose: true, }); - const changes: CsvTranslation[] = []; - changedKeys.forEach((changedKey) => { - const row = translationsMap[changedKey] as CsvTranslation; - changes.push(row); - }); - await cmsDocImportTranslations(props.docId, changes); + await translationsDoc.saveTranslations(); updateNotification({ id: notificationId, title: 'Saved translations', @@ -99,18 +94,8 @@ export function EditTranslationsModal( setSaving(false); } - function onChange(row: Record) { - setTranslationsMap((current) => { - const newValue = {...current}; - newValue[row.source] = row; - return newValue; - }); - setChangedKeys((current) => { - const newValue = new Set(current); - newValue.add(row.source); - return Array.from(newValue); - }); - setHasChanges(true); + function onChange(locale: string, source: string, translation: string) { + translationsDoc.setTranslation(locale, source, translation); } return ( @@ -195,7 +180,7 @@ export function EditTranslationsModal( size="xs" color="dark" onClick={() => onSave()} - disabled={!hasChanges} + disabled={!translationsDoc.hasPendingChanges} loading={saving} > Save @@ -210,32 +195,21 @@ EditTranslationsModal.StringsEditor = (props: { source: string; locales: string[]; translations: Record; - onChange: (row: Record) => void; + onChange: (locale: string, source: string, translation: string) => void; }) => { const locales = props.locales; const [translations, setTranslations] = useState>( props.translations || {} ); - const [hasChanges, setHasChanges] = useState(false); - function updateTranslation(locale: string, value: string) { - setHasChanges(true); - setTranslations((current) => { - const newTranslations: Record = { - ...current, - source: props.source, - }; - newTranslations[locale] = value; - return newTranslations; + function updateTranslation(locale: string, translation: string) { + setTranslations((translations) => { + translations[locale] = translation; + return translations; }); + props.onChange(locale, props.source, translation); } - useEffect(() => { - if (hasChanges) { - props.onChange(translations); - } - }, [hasChanges, translations]); - return (
{locales.map((locale) => ( diff --git a/packages/root-cms/ui/components/ExportSheetModal/ExportSheetModal.tsx b/packages/root-cms/ui/components/ExportSheetModal/ExportSheetModal.tsx index 27e2e1da..d68111e6 100644 --- a/packages/root-cms/ui/components/ExportSheetModal/ExportSheetModal.tsx +++ b/packages/root-cms/ui/components/ExportSheetModal/ExportSheetModal.tsx @@ -3,17 +3,17 @@ import {ContextModalProps, useModals} from '@mantine/modals'; import {showNotification, updateNotification} from '@mantine/notifications'; import {ChangeEvent, forwardRef} from 'preact/compat'; import {useState} from 'preact/hooks'; -import {useGapiClient} from '../../hooks/useGapiClient.js'; -import {useModalTheme} from '../../hooks/useModalTheme.js'; -import {cmsLinkGoogleSheetL10n} from '../../utils/doc.js'; +import {Text} from '@/components/Text/Text.js'; +import {dbTranslationsLinkGoogleSheet} from '@/db/translations.js'; +import {useGapiClient} from '@/hooks/useGapiClient.js'; +import {useModalTheme} from '@/hooks/useModalTheme.js'; import { GSheet, GSpreadsheet, getSpreadsheetUrl, parseSpreadsheetUrl, -} from '../../utils/gsheets.js'; -import {notifyErrors} from '../../utils/notifications.js'; -import {Text} from '../Text/Text.js'; +} from '@/utils/gsheets.js'; +import {notifyErrors} from '@/utils/notifications.js'; import './ExportSheetModal.css'; const MODAL_ID = 'ExportSheetModal'; @@ -22,7 +22,7 @@ export type Action = 'new-sheet' | 'add-tab' | 'link-sheet'; export interface ExportSheetModalProps { [key: string]: unknown; - docId: string; + translationsId: string; csvData: {headers: string[]; rows: Record[]}; locales: string[]; } @@ -133,8 +133,8 @@ export function ExportSheetModal( if (!gsheet) { throw new Error('could not find sheet gid=0'); } - // Update tab name to the doc id. - gsheet.setTitle(props.docId); + // Update tab name to the translationsId. + gsheet.setTitle(props.translationsId); } catch (err) { console.error(err); let msg = err; @@ -165,7 +165,7 @@ export function ExportSheetModal( spreadsheetId: gspreadsheet.spreadsheetId, gid: 0, }; - await cmsLinkGoogleSheetL10n(props.docId, linkedSheet); + await dbTranslationsLinkGoogleSheet(props.translationsId, linkedSheet); setSheetUrl(gsheet.getUrl()); } catch (err) { console.error(err); @@ -238,7 +238,7 @@ export function ExportSheetModal( autoClose: false, disallowClose: true, }); - gsheet = await gspreadsheet.createSheet({title: props.docId}); + gsheet = await gspreadsheet.createSheet({title: props.translationsId}); } catch (err) { console.error(err); let msg = err; @@ -269,7 +269,7 @@ export function ExportSheetModal( spreadsheetId: gspreadsheet.spreadsheetId, gid: gsheet.gid, }; - await cmsLinkGoogleSheetL10n(props.docId, linkedSheet); + await dbTranslationsLinkGoogleSheet(props.translationsId, linkedSheet); } catch (err) { console.error(err); updateNotification({ @@ -334,7 +334,7 @@ export function ExportSheetModal( autoClose: false, disallowClose: true, }); - await cmsLinkGoogleSheetL10n(props.docId, gsheetId); + await dbTranslationsLinkGoogleSheet(props.translationsId, gsheetId); } catch (err) { console.error(err); updateNotification({ @@ -385,7 +385,7 @@ export function ExportSheetModal(