Skip to content

Commit

Permalink
feat: Translation Api (#515)
Browse files Browse the repository at this point in the history
* initial implementation of `TranslationApi`

* * add @types/json-stable-strinfify
* start writing unit tests
* error handling for missing translation
* fix error in sql query

* * remove `message` from object when hashing
* fix logic error when `put`
* handle unexisting key in the indexing (by creating a new Set)
* make `fieldRef` and `regionCode` optional when `get`

* improve `get()` by ignoring not translated languages, improve typing

* * make `index` private, run it on `put`
* expose cache map through symbol and getter
* improve `get` signature (by making `fieldRef` and `regionCode`
  optional)
* add more unit tests

* revert `index` as private method, update tests

* update magic-bytes manually

* add e2e/translation-api.js and expose translation api in mapeo project

* * add translationTable to index writer
* add translationDoc to entries for batching indexer
* move decoding of translationDoc into `.index` function in
  translationApi

* rever changes to `.index` method (better to accept doc than block)

* first e2e tests

* revert regionCode fallback (since its handled in an upper layer)

* use default config for translationApi e2e tests, test with a bunch of
translations

* add translation fixtures

* add check of expected translation

* improve test messages

* add assertion of matching preset docId with translation docIdRef

* add tests and fixture for fields

* chore: use private members for TranslationApi (#579)

* chore: ensure translation tests are checking something (#578)

These tests iterate over various documents to check things. If, somehow,
we had 0 documents, the test would pass incorrectly.

This fixes that by checking that we have at least one test doc.

* Apply suggestions from code review

1. replace conditional with assertion
2. remove comment in `get` signature
3. remove `?` from `translatedSchemas.add`
4. Add comment to advice only using private symbol in tests

Co-authored-by: Evan Hahn <[email protected]>

* Fix `assert` not being present

* perf test, return full doc from create

* fix tests after api change

* comment perf test and re indexing of translations on translationAPI
constructor

* added cache tests

* simplify `put` logic

* doc can be a const inside try now

* add return type to `get`

Co-authored-by: Evan Hahn <[email protected]>

* chore: add "ready" promise to translation API (#589)

* chore: use `instanceof`, not message, for "not found" error (#590)

---------

Co-authored-by: Tomás Ciccola <[email protected]>
Co-authored-by: Evan Hahn <[email protected]>
  • Loading branch information
3 people authored Apr 26, 2024
1 parent 782cfe5 commit 9fa05aa
Show file tree
Hide file tree
Showing 11 changed files with 779 additions and 207 deletions.
320 changes: 115 additions & 205 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"@types/bogon": "^1.0.2",
"@types/debug": "^4.1.8",
"@types/json-schema": "^7.0.11",
"@types/json-stable-stringify": "^1.0.36",
"@types/nanobench": "^3.0.0",
"@types/node": "^18.16.3",
"@types/sinonjs__fake-timers": "^8.1.2",
Expand Down Expand Up @@ -144,6 +145,7 @@
"hypercore-crypto": "3.4.0",
"hyperdrive": "11.5.3",
"hyperswarm": "4.4.1",
"json-stable-stringify": "^1.1.1",
"magic-bytes.js": "^1.10.0",
"map-obj": "^5.0.2",
"mime": "^4.0.1",
Expand Down
3 changes: 2 additions & 1 deletion src/datatype/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getTableConfig } from 'drizzle-orm/sqlite-core'
import { eq, inArray, sql } from 'drizzle-orm'
import { randomBytes } from 'node:crypto'
import { noop, deNullify } from '../utils.js'
import { NotFoundError } from '../errors.js'
import crypto from 'hypercore-crypto'
import { TypedEmitter } from 'tiny-typed-emitter'

Expand Down Expand Up @@ -175,7 +176,7 @@ export class DataType extends TypedEmitter {
async getByDocId(docId) {
await this.#dataStore.indexer.idle()
const result = this.#sql.getByDocId.get({ docId })
if (!result) throw new Error('Not found')
if (!result) throw new NotFoundError()
return deNullify(result)
}

Expand Down
7 changes: 7 additions & 0 deletions src/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// @ts-check

export class NotFoundError extends Error {
constructor() {
super('Not found')
}
}
30 changes: 29 additions & 1 deletion src/mapeo-project.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check
import path from 'path'
import Database from 'better-sqlite3'
import { decodeBlockPrefix } from '@mapeo/schema'
import { decodeBlockPrefix, decode } from '@mapeo/schema'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
import { discoveryKey } from 'hypercore-crypto'
Expand All @@ -24,6 +24,7 @@ import {
presetTable,
roleTable,
iconTable,
translationTable,
} from './schema/project.js'
import {
CoreOwnership,
Expand All @@ -37,6 +38,7 @@ import {
LEFT_ROLE_ID,
} from './roles.js'
import {
assert,
getDeviceId,
projectKeyToId,
projectKeyToPublicId,
Expand All @@ -47,6 +49,7 @@ import { SyncApi, kHandleDiscoveryKey } from './sync/sync-api.js'
import { Logger } from './logger.js'
import { IconApi } from './icon-api.js'
import { readConfig } from './config-import.js'
import TranslationApi from './translation-api.js'

/** @typedef {Omit<import('@mapeo/schema').ProjectSettingsValue, 'schemaName'>} EditableProjectSettings */

Expand Down Expand Up @@ -80,6 +83,7 @@ export class MapeoProject extends TypedEmitter {
#memberApi
#iconApi
#syncApi
#translationApi
#l

static EMPTY_PROJECT_SETTINGS = EMPTY_PROJECT_SETTINGS
Expand Down Expand Up @@ -157,6 +161,7 @@ export class MapeoProject extends TypedEmitter {
roleTable,
deviceInfoTable,
iconTable,
translationTable,
],
sqlite: this.#sqlite,
getWinner,
Expand Down Expand Up @@ -242,6 +247,11 @@ export class MapeoProject extends TypedEmitter {
table: iconTable,
db,
}),
translation: new DataType({
dataStore: this.#dataStores.config,
table: translationTable,
db,
}),
}
const identityKeypair = keyManager.getIdentityKeypair()
const coreKeypairs = getCoreKeypairs({
Expand Down Expand Up @@ -310,6 +320,11 @@ export class MapeoProject extends TypedEmitter {
logger: this.#l,
})

this.#translationApi = new TranslationApi({
dataType: this.#dataTypes.translation,
table: translationTable,
})

///////// 4. Replicate local peers automatically

// Replicate already connected local peers
Expand Down Expand Up @@ -420,6 +435,15 @@ export class MapeoProject extends TypedEmitter {

if (schemaName === 'projectSettings') {
projectSettingsEntries.push(entry)
} else if (schemaName === 'translation') {
const doc = decode(entry.block, {
coreDiscoveryKey: entry.key,
index: entry.index,
})

assert(doc.schemaName === 'translation', 'expected a translation doc')
this.#translationApi.index(doc)
otherEntries.push(entry)
} else {
otherEntries.push(entry)
}
Expand Down Expand Up @@ -458,6 +482,10 @@ export class MapeoProject extends TypedEmitter {
return this.#syncApi
}

get $translation() {
return this.#translationApi
}

/**
* @param {Partial<EditableProjectSettings>} settings
* @returns {Promise<EditableProjectSettings>}
Expand Down
129 changes: 129 additions & 0 deletions src/translation-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { and, eq } from 'drizzle-orm'
import { kCreateWithDocId, kSelect } from './datatype/index.js'
import { hashObject } from './utils.js'
import { NotFoundError } from './errors.js'

export const ktranslatedLanguageCodeToSchemaNames = Symbol(
'translatedLanguageCodeToSchemaNames'
)
export default class TranslationApi {
/** @type {Map<
* import('@mapeo/schema').TranslationValue['languageCode'],
* Set<import('@mapeo/schema/dist/types.js').SchemaName>>} */
#translatedLanguageCodeToSchemaNames = new Map()
#dataType
#table
#indexPromise

/**
* @param {Object} opts
* @param {import('./datatype/index.js').DataType<
* import('./datastore/index.js').DataStore<'config'>,
* typeof import('./schema/project.js').translationTable,
* 'translation',
* import('@mapeo/schema').Translation,
* import('@mapeo/schema').TranslationValue
* >} opts.dataType
* @param {typeof import('./schema/project.js').translationTable} opts.table
*/
constructor({ dataType, table }) {
this.#dataType = dataType
this.#table = table
this.#indexPromise = this.#dataType
.getMany()
.then((docs) => {
docs.map((doc) => this.index(doc))
})
.catch((err) => {
throw new Error(`error loading Translation cache: ${err}`)
})
}

/** @returns {Promise<void>} */
ready() {
return this.#indexPromise
}

/**
* @param {import('@mapeo/schema').TranslationValue} value
*/
async put(value) {
/* eslint-disable no-unused-vars */
const { message, ...identifiers } = value
const docId = hashObject(identifiers)
try {
const doc = await this.#dataType.getByDocId(docId)
return await this.#dataType.update(doc.versionId, value)
} catch (e) {
if (e instanceof NotFoundError) {
return await this.#dataType[kCreateWithDocId](docId, value)
} else {
throw new Error(`Error on translation ${e}`)
}
}
}

/**
* @param {import('type-fest').SetOptional<
* Omit<import('@mapeo/schema').TranslationValue,'schemaName' | 'message'>,
* 'fieldRef' | 'regionCode'>} value
* @returns {Promise<import('@mapeo/schema').Translation[]>}
*/
async get(value) {
await this.ready()

const docTypeIsTranslatedToLanguage =
this.#translatedLanguageCodeToSchemaNames
.get(value.languageCode)
?.has(
/** @type {import('@mapeo/schema/dist/types.js').SchemaName} */ (
value.schemaNameRef
)
)
if (!docTypeIsTranslatedToLanguage) return []

const filters = [
eq(this.#table.docIdRef, value.docIdRef),
eq(this.#table.schemaNameRef, value.schemaNameRef),
eq(this.#table.languageCode, value.languageCode),
]
if (value.fieldRef) {
filters.push(eq(this.#table.fieldRef, value.fieldRef))
}

if (value.regionCode) {
filters.push(eq(this.#table.regionCode, value.regionCode))
}

return (await this.#dataType[kSelect]())
.where(and.apply(null, filters))
.prepare()
.all()
}

/**
* @param {import('@mapeo/schema').TranslationValue} doc
*/
index(doc) {
let translatedSchemas = this.#translatedLanguageCodeToSchemaNames.get(
doc.languageCode
)
if (!translatedSchemas) {
translatedSchemas = new Set()
this.#translatedLanguageCodeToSchemaNames.set(
doc.languageCode,
translatedSchemas
)
}
translatedSchemas.add(
/** @type {import('@mapeo/schema/dist/types.js').SchemaName} */ (
doc.schemaNameRef
)
)
}

// This should only be used by tests.
get [ktranslatedLanguageCodeToSchemaNames]() {
return this.#translatedLanguageCodeToSchemaNames
}
}
14 changes: 14 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import b4a from 'b4a'
import { keyToPublicId } from '@mapeo/crypto'
import { createHash } from 'node:crypto'
import stableStringify from 'json-stable-stringify'

/**
* @param {String|Buffer} id
Expand Down Expand Up @@ -191,3 +193,15 @@ export function createMap(keys, value) {
}
return map
}

/**
* create a sha256 hash of an object using json-stable-stringify for deterministic results
* @param {Object} obj
* @returns {String} hash of the object
*/
export function hashObject(obj) {
return createHash('sha256')
.update(stableStringify(obj))
.digest()
.toString('hex')
}
74 changes: 74 additions & 0 deletions test-e2e/fixtures/translations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const commonPreset = {
/** @type {'translation'} */
schemaName: 'translation',
schemaNameRef: 'preset',
languageCode: 'es',
regionCode: 'AR',
fieldRef: 'name',
}

const commonField = {
/** @type {'translation'} */
schemaName: 'translation',
schemaNameRef: 'field',
languageCode: 'es',
regionCode: 'AR',
fieldRef: 'label',
}

/** @type {Object.<string,string>} */
export const presetsTranslationMap = {
Airstrip: 'Pista de Aterrizaje',
Boundry: 'Límite',
Cave: 'Cueva',
Building: 'Edificio',
Clay: 'Arcilla',
'New Area': 'Nueva Área',
Camp: 'Campamento',
Community: 'Comunidad',
'Gathering Site': 'Zona de recolección',
Hills: 'Colinas',
House: 'Casa',
'Hunting Site': 'Sitio de Caza',
'Fishing Site': 'Sitio de Pesca',
Palm: 'Palma',
Plant: 'Planta',
Path: 'Camino',
'New point': 'Nuevo punto',
River: 'Río',
'New line': 'Nueva línea',
Lake: 'Lago',
Stream: 'Cauce',
'Special site': 'Sitio especial',
Farmland: 'Tierra de cultivo',
Threat: 'Amenaza',
Waterfall: 'Cascada',
Tree: 'Árbol',
}

/** @type {Object.<string,string>} */
export const fieldsTranslationMap = {
'Animal type': 'Tipo de animal',
'Building type': 'Tipo de edificio',
'What is gathered here?': '¿Qué se recolecta aquí?',
Note: 'Nota',
Owner: 'Dueño',
'Plant species': 'Especie de planta',
'What kind of path?': '¿Qué clase de camino?',
Name: 'Nombre',
'Tree species': 'Especie de árbol',
}

export const presetTranslations = Object.keys(presetsTranslationMap).map(
(key) => {
const translation = presetsTranslationMap[key]
return { ...commonPreset, message: translation }
}
)

export const fieldTranslations = Object.keys(fieldsTranslationMap).map(
(key) => {
const translation = fieldsTranslationMap[key]
return { ...commonField, message: translation }
}
)
Loading

0 comments on commit 9fa05aa

Please sign in to comment.