snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
2
packages/frontend/i18n/.gitignore
vendored
Normal file
2
packages/frontend/i18n/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
lib
|
||||
i18n-generated.ts
|
||||
21
packages/frontend/i18n/.i18n-codegen.json
Normal file
21
packages/frontend/i18n/.i18n-codegen.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "./node_modules/@magic-works/i18n-codegen/schema.json",
|
||||
"version": 1,
|
||||
"list": [
|
||||
{
|
||||
"input": "./src/resources/en.json",
|
||||
"output": "./src/i18n.gen",
|
||||
"parser": {
|
||||
"type": "i18next",
|
||||
"contextSeparator": "$",
|
||||
"pluralSeparator": "_"
|
||||
},
|
||||
"generator": {
|
||||
"type": "i18next/react-hooks",
|
||||
"hooks": "useAFFiNEI18N",
|
||||
"emitTS": true,
|
||||
"shouldUnescape": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
67
packages/frontend/i18n/README.md
Normal file
67
packages/frontend/i18n/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# i18n
|
||||
|
||||
## Usages
|
||||
|
||||
- Update missing translations into the base resources, a.k.a the `src/resources/en.json`
|
||||
- Replace literal text with translation keys
|
||||
|
||||
```tsx
|
||||
import { useI18n, LOCALES } from '@affine/i18n';
|
||||
// src/resources/en.json
|
||||
// {
|
||||
// 'Text': 'some text',
|
||||
// 'Switch to language': 'Switch to {{language}}', // <- you can interpolation by curly brackets
|
||||
// };
|
||||
|
||||
const App = () => {
|
||||
const i18n = useI18n();
|
||||
const changeLanguage = (language: string) => {
|
||||
i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{i18n['Workspace Settings']()}</div>
|
||||
<>
|
||||
{LOCALES.map(option => {
|
||||
return (
|
||||
<button
|
||||
key={option.name}
|
||||
onClick={() => {
|
||||
changeLanguage(option.tag);
|
||||
}}
|
||||
>
|
||||
{option.originalName}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## How the i18n workflow works?
|
||||
|
||||
- When the `src/resources/en.json`(base language) updated and merged to the develop branch, will trigger the `languages-sync` action.
|
||||
- The `languages-sync` action will check the base language and add missing translations to the Tolgee platform.
|
||||
- This way, partners from the community can update the translations.
|
||||
|
||||
## How to sync translations manually
|
||||
|
||||
- Set token as environment variable
|
||||
|
||||
```shell
|
||||
export TOLGEE_API_KEY=tgpak_XXXXXXX
|
||||
```
|
||||
|
||||
- Run the `sync-languages:check` to check all languages
|
||||
- Run the `sync-languages` script to add new keys to the Tolgee platform
|
||||
- Run the `download-resources` script to download the latest full-translation translation resources from the Tolgee platform
|
||||
|
||||
## References
|
||||
|
||||
- [AFFiNE | Tolgee](https://i18n.affine.pro/)
|
||||
- [Tolgee Documentation](https://tolgee.io/docs/)
|
||||
- [i18next](https://www.i18next.com/)
|
||||
- [react-i18next](https://react.i18next.com/)
|
||||
241
packages/frontend/i18n/build.ts
Normal file
241
packages/frontend/i18n/build.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { parse } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { ProjectRoot } from '@affine-tools/utils/path';
|
||||
import { Package } from '@affine-tools/utils/workspace';
|
||||
import { runCli } from '@magic-works/i18n-codegen';
|
||||
import { glob } from 'glob';
|
||||
|
||||
const isDev = process.argv.includes('--dev');
|
||||
const shouldCleanup = process.argv.includes('--cleanup');
|
||||
|
||||
const i18nPkg = new Package('@affine/i18n');
|
||||
const resourcesDir = i18nPkg.join('src', 'resources').toString();
|
||||
|
||||
function readResource(lang: string): Record<string, string> {
|
||||
const filePath = `${resourcesDir}/${lang}.json`;
|
||||
const fileContent = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return fileContent;
|
||||
}
|
||||
|
||||
function writeResource(lang: string, resource: Record<string, string>) {
|
||||
const filePath = `${resourcesDir}/${lang}.json`;
|
||||
writeFileSync(filePath, JSON.stringify(resource, null, 2) + '\n');
|
||||
}
|
||||
|
||||
async function cleanupResources() {
|
||||
const escapeRegExp = (value: string) =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const files = await glob('packages/frontend/**/src/**/*.{js,tsx,ts}', {
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/packages/frontend/i18n/src/resources/*',
|
||||
'**/packages/frontend/i18n/src/i18n.gen.ts',
|
||||
'**/dist/**',
|
||||
'**/lib/**',
|
||||
],
|
||||
cwd: ProjectRoot.toString(),
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
const filesWithContent = files.map(file => {
|
||||
return {
|
||||
path: file,
|
||||
content: readFileSync(file, 'utf8'),
|
||||
};
|
||||
});
|
||||
|
||||
const dynamicPrefixes = new Set<string>();
|
||||
const templatePrefixRegex = /`[^`]*?(com\.affine\.[^`]*?)\$\{/g;
|
||||
const concatPrefixRegex = /['"](com\.affine\.[^'"]*?\.)['"]\s*\+/g;
|
||||
const addDynamicPrefix = (rawPrefix: string) => {
|
||||
let prefix = rawPrefix;
|
||||
if (!prefix.endsWith('.')) {
|
||||
const lastDot = prefix.lastIndexOf('.');
|
||||
if (lastDot === -1) {
|
||||
return;
|
||||
}
|
||||
prefix = prefix.slice(0, lastDot + 1);
|
||||
}
|
||||
dynamicPrefixes.add(prefix);
|
||||
};
|
||||
|
||||
for (const file of filesWithContent) {
|
||||
templatePrefixRegex.lastIndex = 0;
|
||||
concatPrefixRegex.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = templatePrefixRegex.exec(file.content)) !== null) {
|
||||
addDynamicPrefix(match[1]);
|
||||
}
|
||||
while ((match = concatPrefixRegex.exec(file.content)) !== null) {
|
||||
addDynamicPrefix(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const resources = readdirSync(resourcesDir)
|
||||
.filter(file => file.endsWith('.json'))
|
||||
.reduce(
|
||||
(langs, file) => {
|
||||
const lang = parse(file).name;
|
||||
langs[lang] = readResource(lang);
|
||||
return langs;
|
||||
},
|
||||
{} as Record<string, Record<string, string>>
|
||||
);
|
||||
|
||||
const candidateKeys = new Set<string>();
|
||||
|
||||
for (const resource of Object.values(resources)) {
|
||||
Object.keys(resource).forEach(key => {
|
||||
if (!key.startsWith('com.affine.payment.modal.')) {
|
||||
candidateKeys.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const unusedKeys = Array.from(candidateKeys).filter(key => {
|
||||
const regex1 = new RegExp(`[\`'"]${escapeRegExp(key)}[\`'"]`, 'g');
|
||||
const lastDot = key.lastIndexOf('.');
|
||||
const keyPrefix = lastDot === -1 ? '' : key.slice(0, lastDot + 1);
|
||||
if (keyPrefix && dynamicPrefixes.has(keyPrefix)) {
|
||||
return false;
|
||||
}
|
||||
for (const file of filesWithContent) {
|
||||
const match = file.content.match(regex1);
|
||||
if (match) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (unusedKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unusedKeySet = new Set(unusedKeys);
|
||||
|
||||
for (const [lang, resource] of Object.entries(resources)) {
|
||||
let changed = false;
|
||||
for (const key of Object.keys(resource)) {
|
||||
if (unusedKeySet.has(key)) {
|
||||
delete resource[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
writeResource(lang, resource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calcCompletenesses() {
|
||||
const langs = readdirSync(resourcesDir)
|
||||
.filter(file => file.endsWith('.json'))
|
||||
.reduce(
|
||||
(langs, file) => {
|
||||
const lang = parse(file).name;
|
||||
langs[lang] = readResource(lang);
|
||||
return langs;
|
||||
},
|
||||
{} as Record<string, Record<string, string>>
|
||||
);
|
||||
|
||||
const base = Object.keys(langs.en).length;
|
||||
|
||||
const completenesses = {};
|
||||
|
||||
for (const key in langs) {
|
||||
const [langPart, variantPart] = key.split('-');
|
||||
|
||||
const completeness = Object.keys(
|
||||
variantPart ? { ...langs[langPart], ...langs[key] } : langs[key]
|
||||
).length;
|
||||
|
||||
completenesses[key] = Math.min(
|
||||
Math.ceil(/* avoid 0% */ (completeness / base) * 100),
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
i18nPkg.join('src', 'i18n-completenesses.json').toString(),
|
||||
JSON.stringify(completenesses, null, 2) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
function i18nnext() {
|
||||
runCli(
|
||||
{
|
||||
config: fileURLToPath(new URL('./.i18n-codegen.json', import.meta.url)),
|
||||
watch: isDev,
|
||||
},
|
||||
error => {
|
||||
console.error(error);
|
||||
if (!isDev) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function appendErrorI18n() {
|
||||
const server = new Package('@affine/server');
|
||||
const defFilePath = server.srcPath.join('base/error/def.ts');
|
||||
|
||||
if (!defFilePath.exists()) {
|
||||
throw new Error(
|
||||
`Can not find Server I18n error definition file. It's not placed at [${defFilePath.relativePath}].`
|
||||
);
|
||||
}
|
||||
|
||||
const { USER_FRIENDLY_ERRORS } = await import(
|
||||
defFilePath.toFileUrl().toString()
|
||||
);
|
||||
|
||||
if (!USER_FRIENDLY_ERRORS) {
|
||||
throw new Error(
|
||||
`Can not find Server I18n error definition file. It's not placed at [${defFilePath.relativePath}] with name [USER_FRIENDLY_ERRORS].`
|
||||
);
|
||||
}
|
||||
|
||||
const en = readResource('en');
|
||||
|
||||
Object.keys(en).forEach(key => {
|
||||
if (key.startsWith('error.')) {
|
||||
delete en[key];
|
||||
}
|
||||
});
|
||||
|
||||
for (const key in USER_FRIENDLY_ERRORS) {
|
||||
const def = USER_FRIENDLY_ERRORS[key] as {
|
||||
type: string;
|
||||
args?: Record<string, any>;
|
||||
message: string | ((args: any) => string);
|
||||
};
|
||||
|
||||
en[`error.${key.toUpperCase()}`] =
|
||||
typeof def.message === 'string'
|
||||
? def.message
|
||||
: def.message(
|
||||
Object.keys(def.args ?? {}).reduce(
|
||||
(args, key) => {
|
||||
args[key] = `{{${key}}}`;
|
||||
return args;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
writeResource('en', en);
|
||||
}
|
||||
|
||||
await appendErrorI18n();
|
||||
if (shouldCleanup) {
|
||||
await cleanupResources();
|
||||
}
|
||||
i18nnext();
|
||||
calcCompletenesses();
|
||||
74
packages/frontend/i18n/cleanup.mjs
Normal file
74
packages/frontend/i18n/cleanup.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
// this script is used to clean up the unused keys in the i18n file
|
||||
// just run `node packages/frontend/i18n/cleanup.mjs`
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { glob } from 'glob';
|
||||
|
||||
const REPO_ROOT = path.resolve(
|
||||
fileURLToPath(import.meta.url),
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..'
|
||||
);
|
||||
|
||||
const files = await glob('packages/frontend/**/src/**/*.{js,tsx,ts}', {
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/packages/frontend/i18n/src/resources/*',
|
||||
'**/dist/**',
|
||||
'**/lib/**',
|
||||
],
|
||||
cwd: REPO_ROOT,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
const filesWithContent = files.map(file => {
|
||||
return {
|
||||
path: file,
|
||||
content: fs.readFileSync(file, 'utf8'),
|
||||
};
|
||||
});
|
||||
|
||||
const enjson = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'packages/frontend/i18n/src/resources/en.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
|
||||
const keys = Object.keys(enjson).filter(
|
||||
// exceptions
|
||||
key => !key.startsWith('com.affine.payment.modal.')
|
||||
);
|
||||
|
||||
const unusedKeys = keys.filter(key => {
|
||||
const regex1 = new RegExp(`[\`'"]${key.replace('.', '\\.')}[\`'"]`, 'g');
|
||||
// some place use i18n key like `t[`com.affine.modal.${var}`]`
|
||||
// com.affine.modal.confirm -> com.affine.modal.
|
||||
const keyWithoutLastDot = key.replace(/(?<=\.)[^.]+$/, '');
|
||||
const regex2 = new RegExp(
|
||||
`[\`'"]${keyWithoutLastDot.replace('.', '\\.')}`,
|
||||
'g'
|
||||
);
|
||||
for (const file of filesWithContent) {
|
||||
const match = file.content.match(regex1) || file.content.match(regex2);
|
||||
if (match) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const key of unusedKeys) {
|
||||
delete enjson[key];
|
||||
}
|
||||
|
||||
// write back to file
|
||||
fs.writeFileSync(
|
||||
path.join(REPO_ROOT, 'packages/frontend/i18n/src/resources/en.json'),
|
||||
JSON.stringify(enjson, null, 2)
|
||||
);
|
||||
35
packages/frontend/i18n/package.json
Normal file
35
packages/frontend/i18n/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@affine/i18n",
|
||||
"version": "0.27.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "r build.ts",
|
||||
"dev": "r build.ts --dev"
|
||||
},
|
||||
"keywords": [],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/toeverything/AFFiNE.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/debug": "workspace:*",
|
||||
"@magic-works/i18n-codegen": "^0.6.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"i18next": "^25.0.0",
|
||||
"react": "^19.2.1",
|
||||
"react-i18next": "^15.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine-tools/cli": "workspace:*",
|
||||
"@affine-tools/utils": "workspace:*",
|
||||
"glob": "^11.0.0",
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
28
packages/frontend/i18n/src/i18n-completenesses.json
Normal file
28
packages/frontend/i18n/src/i18n-completenesses.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"ar": 87,
|
||||
"ca": 85,
|
||||
"da": 3,
|
||||
"de": 95,
|
||||
"el-GR": 84,
|
||||
"en": 100,
|
||||
"es-AR": 84,
|
||||
"es-CL": 85,
|
||||
"es": 84,
|
||||
"fa": 84,
|
||||
"fr": 87,
|
||||
"hi": 1,
|
||||
"it": 85,
|
||||
"ja": 84,
|
||||
"kk": 90,
|
||||
"ko": 84,
|
||||
"nb-NO": 42,
|
||||
"pl": 85,
|
||||
"pt-BR": 84,
|
||||
"ru": 86,
|
||||
"sv-SE": 84,
|
||||
"tr": 90,
|
||||
"uk": 84,
|
||||
"ur": 90,
|
||||
"zh-Hans": 95,
|
||||
"zh-Hant": 86
|
||||
}
|
||||
171
packages/frontend/i18n/src/i18next.ts
Normal file
171
packages/frontend/i18n/src/i18next.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import type { BackendModule, i18n } from 'i18next';
|
||||
import i18next from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import type { useAFFiNEI18N } from './i18n.gen';
|
||||
import type { Language } from './resources';
|
||||
import { SUPPORTED_LANGUAGES } from './resources';
|
||||
|
||||
const logger = new DebugLogger('i18n');
|
||||
|
||||
const defaultLng: Language = 'en';
|
||||
|
||||
let _instance: i18n | null = null;
|
||||
export const getOrCreateI18n = (): i18n => {
|
||||
if (!_instance) {
|
||||
_instance = i18next.createInstance();
|
||||
_instance
|
||||
.use(initReactI18next)
|
||||
.use({
|
||||
type: 'backend',
|
||||
init: () => {},
|
||||
read: (lng: Language, _ns: string, callback) => {
|
||||
const resource = SUPPORTED_LANGUAGES[lng].resource;
|
||||
if (typeof resource === 'function') {
|
||||
resource()
|
||||
.then(data => {
|
||||
logger.info(`Loaded i18n ${lng} resource`);
|
||||
callback(null, data.default);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error(`Failed to load i18n ${lng} resource`, err);
|
||||
callback(null, null);
|
||||
});
|
||||
} else {
|
||||
callback(null, resource);
|
||||
}
|
||||
},
|
||||
} as BackendModule)
|
||||
.init({
|
||||
lng: defaultLng,
|
||||
fallbackLng: code => {
|
||||
// always fallback to english
|
||||
const fallbacks: string[] = [defaultLng];
|
||||
const langPart = code.split('-')[0];
|
||||
|
||||
// fallback xx-YY to xx, e.g. es-AR to es
|
||||
// fallback zh-Hant to zh-Hans
|
||||
if (langPart === 'cn') {
|
||||
fallbacks.push('zh-Hans');
|
||||
} else if (
|
||||
langPart !== code &&
|
||||
SUPPORTED_LANGUAGES[code as Language]
|
||||
) {
|
||||
fallbacks.unshift(langPart);
|
||||
}
|
||||
|
||||
return fallbacks;
|
||||
},
|
||||
supportedLngs: Object.keys(SUPPORTED_LANGUAGES),
|
||||
debug: false,
|
||||
partialBundledLanguages: true,
|
||||
resources: {
|
||||
[defaultLng]: {
|
||||
translation: SUPPORTED_LANGUAGES[defaultLng].resource,
|
||||
},
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
logger.info('i18n initialized');
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return _instance;
|
||||
};
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
// NOTE(@forehalo):
|
||||
// DO NOT ENABLE THIS
|
||||
// This could bring typecheck for <Trans /> component,
|
||||
// but it will make typecheck of whole codebase so laggy!
|
||||
// check [./react.ts]
|
||||
// resources: {
|
||||
// translation: LanguageResource;
|
||||
// };
|
||||
}
|
||||
}
|
||||
|
||||
export type I18nFuncs = ReturnType<typeof useAFFiNEI18N>;
|
||||
type KnownI18nKey = keyof I18nFuncs;
|
||||
|
||||
export type I18nString =
|
||||
| KnownI18nKey
|
||||
| string
|
||||
| { i18nKey: string; options?: Record<string, any> };
|
||||
|
||||
export function isI18nString(value: unknown): value is I18nString {
|
||||
if (typeof value === 'string') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return 'i18nKey' in value;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createI18nWrapper(getI18nFn: () => i18n) {
|
||||
const I18nMethod = {
|
||||
t(key: I18nString, options?: Record<string, any>) {
|
||||
if (typeof key === 'object' && 'i18nKey' in key) {
|
||||
options = key.options;
|
||||
key = key.i18nKey as string;
|
||||
}
|
||||
|
||||
const i18n = getI18nFn();
|
||||
if (i18n.exists(key)) {
|
||||
return i18n.t(key, options);
|
||||
} else {
|
||||
// unknown translate key 'xxx.xxx' returns itself
|
||||
return key;
|
||||
}
|
||||
},
|
||||
get language() {
|
||||
const i18n = getI18nFn();
|
||||
return i18n.language;
|
||||
},
|
||||
changeLanguage(lng?: string | undefined) {
|
||||
const i18n = getI18nFn();
|
||||
return i18n.changeLanguage(lng);
|
||||
},
|
||||
get on() {
|
||||
const i18n = getI18nFn();
|
||||
return i18n.on.bind(i18n);
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy(I18nMethod, {
|
||||
get(self, key: string) {
|
||||
if (key in self) {
|
||||
// @ts-expect-error allow
|
||||
return self[key];
|
||||
}
|
||||
|
||||
return I18nMethod.t.bind(null, key);
|
||||
},
|
||||
has(self, key: string) {
|
||||
if (key in self) {
|
||||
return true;
|
||||
}
|
||||
const i18n = getI18nFn();
|
||||
if (i18n.exists(key)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}) as typeof I18nMethod &
|
||||
ReturnType<typeof useAFFiNEI18N> & { [unknownKey: string]: () => string };
|
||||
}
|
||||
|
||||
/**
|
||||
* I18n['com.affine.xxx']({ arg1: 'hello' }) -> '中文 hello'
|
||||
*/
|
||||
export const I18n = createI18nWrapper(getOrCreateI18n);
|
||||
export type I18nInstance = typeof I18n;
|
||||
7
packages/frontend/i18n/src/index.ts
Normal file
7
packages/frontend/i18n/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './i18next';
|
||||
export * from './react';
|
||||
export * from './resources';
|
||||
export * from './utils';
|
||||
import completenesses from './i18n-completenesses.json';
|
||||
|
||||
export const i18nCompletenesses = completenesses;
|
||||
12
packages/frontend/i18n/src/react.ts
Normal file
12
packages/frontend/i18n/src/react.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { createI18nWrapper } from './i18next';
|
||||
|
||||
export const useI18n = () => {
|
||||
const { i18n } = useTranslation('translation');
|
||||
|
||||
return useMemo(() => createI18nWrapper(() => i18n), [i18n]);
|
||||
};
|
||||
|
||||
export { I18nextProvider, Trans, useTranslation } from 'react-i18next';
|
||||
77
packages/frontend/i18n/src/resources/da.json
Normal file
77
packages/frontend/i18n/src/resources/da.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"AFFiNE Cloud": "AFFiNE Cloud",
|
||||
"All pages": "Alle sider",
|
||||
"Bold": "Fed",
|
||||
"Confirm": "Bekræft",
|
||||
"Continue": "Fortsæt",
|
||||
"Copy": "Kopier",
|
||||
"Create": "Opret",
|
||||
"Created": "Oprettet",
|
||||
"Date": "Dato",
|
||||
"Delete": "Slet",
|
||||
"Disable": "Deaktiver",
|
||||
"Edgeless": "Kantløs",
|
||||
"Edit": "Redigér",
|
||||
"Enable": "Activér",
|
||||
"Export": "Eksportér",
|
||||
"Export to HTML": "Eksportér til HTML",
|
||||
"Export to Markdown": "Eksportér til Markdown",
|
||||
"Export to PDF": "Eksportér til PDF",
|
||||
"Export to PNG": "Eksportér til PNG",
|
||||
"FILE_ALREADY_EXISTS": "Filen eksisterer allerede",
|
||||
"Got it": "Forstået",
|
||||
"Heading": "Overskrift {{number}}",
|
||||
"Image": "Billede",
|
||||
"Import": "Importer",
|
||||
"Info": "Info",
|
||||
"Invite": "Inviter",
|
||||
"Invite Members": "Inviter medlemmer",
|
||||
"Leave": "Forlad",
|
||||
"Link": "Link (med vælgt tekst)",
|
||||
"Loading": "Indlæser...",
|
||||
"Member": "Medlem",
|
||||
"Members": "Medlemmer",
|
||||
"New Page": "Ny side",
|
||||
"Owner": "Ejer",
|
||||
"Page": "Side",
|
||||
"Pen": "Pen",
|
||||
"Pending": "Afventer",
|
||||
"Publish": "Udgiv",
|
||||
"Published to Web": "Udgivet online",
|
||||
"Quick Search": "Hurtig Søgning",
|
||||
"Quick search": "Hurtig søgning",
|
||||
"Recent": "Nylig",
|
||||
"Rename": "Omdøb",
|
||||
"Save": "Gem",
|
||||
"Select": "Vælg",
|
||||
"Switch": "Skift",
|
||||
"Sync": "Synkroniser",
|
||||
"Tags": "Tags",
|
||||
"Text": "Tekst",
|
||||
"Theme": "Tema",
|
||||
"Title": "Titel",
|
||||
"Trash": "Papirkurv",
|
||||
"Undo": "Fortryd",
|
||||
"Unpin": "Frigør",
|
||||
"Untitled": "Unavnet",
|
||||
"Users": "Bruger",
|
||||
"Version": "Version",
|
||||
"all": "alt",
|
||||
"com.affine.edgelessMode": "Kantløs Mode",
|
||||
"com.affine.filter": "Filtrer",
|
||||
"com.affine.filter.after": "efter",
|
||||
"com.affine.filter.before": "før",
|
||||
"com.affine.filter.contains all": "indeholder alt",
|
||||
"com.affine.filter.is": "Er",
|
||||
"com.affine.lastMonth": "Sidste måned",
|
||||
"com.affine.lastWeek": "Sidste uge",
|
||||
"com.affine.lastYear": "Sidste år",
|
||||
"com.affine.new_import": "Importer",
|
||||
"com.affine.settings.appearance": "Udseende",
|
||||
"com.affine.today": "I dag",
|
||||
"com.affine.yesterday": "I går",
|
||||
"dark": "Mørk",
|
||||
"light": "Lys",
|
||||
"others": "Andre",
|
||||
"system": "System"
|
||||
}
|
||||
47
packages/frontend/i18n/src/resources/es-AR.json
Normal file
47
packages/frontend/i18n/src/resources/es-AR.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"404.back": "Volver a Mi Contenido",
|
||||
"404.hint": "Disculpas, no tenés acceso o este contenido no existe...",
|
||||
"404.signOut": "Iniciar sesión con otra cuenta",
|
||||
"AFFiNE Cloud": "Nube AFFiNE",
|
||||
"All pages": "Todos los documentos",
|
||||
"App Version": "Versión de la Aplicación",
|
||||
"Available Offline": "Disponible sin conexión",
|
||||
"Bold": "Negrita",
|
||||
"Cancel": "Cancelar",
|
||||
"Click to replace photo": "Clickear para reemplazar foto",
|
||||
"Collections": "Colleciones",
|
||||
"Confirm": "Confirmar",
|
||||
"Continue": "Continuar",
|
||||
"Convert to ": "Convertir a",
|
||||
"Copied link to clipboard": "Copiar hipervínculo al portapapeles",
|
||||
"Copy": "Copiar",
|
||||
"Create": "Crear",
|
||||
"Created": "Creado",
|
||||
"Customize": "Personalizar",
|
||||
"DB_FILE_ALREADY_LOADED": "Archivo de base de datos ya cargado",
|
||||
"DB_FILE_INVALID": "Archivo de Base de datos inválido",
|
||||
"DB_FILE_MIGRATION_FAILED": "La migración de la Base de datos falló",
|
||||
"DB_FILE_PATH_INVALID": "Ruta de Base de datos inválida",
|
||||
"Date": "Fecha",
|
||||
"Delete": "Borrar",
|
||||
"Disable": "Deshabilitar",
|
||||
"Disable Public Sharing": "Deshabilitar el uso compartido público",
|
||||
"Divider": "Divisor",
|
||||
"Edgeless": "Sin bordes",
|
||||
"Edit": "Editar",
|
||||
"Editor Version": "Versión del editor",
|
||||
"Enable": "Habilitar",
|
||||
"Export": "Exportar",
|
||||
"Export Description": "Podés exportar el Espacio de trabajo completo para respaldo, y la información exportada puede ser re-importada.",
|
||||
"Export failed": "Exportación fallida",
|
||||
"Export success": "Exportación exitosa",
|
||||
"Export to HTML": "Exportar a HTML",
|
||||
"Export to Markdown": "Exportar a Markdown",
|
||||
"Export to PDF": "Exportar a PDF",
|
||||
"Export to PNG": "Exportar a PNG",
|
||||
"FILE_ALREADY_EXISTS": "El archivo ya existe",
|
||||
"Favorite": "Favorito",
|
||||
"Favorited": "Favoritos",
|
||||
"Favorites": "Favoritos",
|
||||
"Find 0 result": "Encontrados 0 resultados"
|
||||
}
|
||||
39
packages/frontend/i18n/src/resources/es-CL.json
Normal file
39
packages/frontend/i18n/src/resources/es-CL.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"404 - Page Not Found": "Error 404 - Página no encontrada",
|
||||
"AFFiNE Cloud": "AFFiNE en la Nube",
|
||||
"AFFiNE Community": "Comunidad AFFiNE",
|
||||
"About AFFiNE": "Qué es AFFiNE?",
|
||||
"Access level": "Nivel de acceso",
|
||||
"Add Filter": "Agregar filtro",
|
||||
"Add Workspace": "Agregar Espacio de Trabajo",
|
||||
"Add Workspace Hint": "Selecciona una base de datos existente",
|
||||
"Add a subpage inside": "Agrega una subpágina",
|
||||
"Add to Favorites": "Agregar a Favoritos",
|
||||
"Add to favorites": "Agregar a favoritos",
|
||||
"Added Successfully": "Exito! Agregado!",
|
||||
"Added to Favorites": "Agregado a Favoritos",
|
||||
"All changes are saved locally": "Todos los cambios guardados en local.",
|
||||
"All data has been stored in the cloud": "Todos los datos han sido guardados en la nube.",
|
||||
"All pages": "Todas las páginas",
|
||||
"Appearance Settings": "Personalizar Apariencia",
|
||||
"Append to Daily Note": "Agregar a Nota Diaria",
|
||||
"Available Offline": "Disponible sin conexión",
|
||||
"Back Home": "Volver al Inicio",
|
||||
"Back to Quick Search": "Volver a búsqueda rápida",
|
||||
"Continue with Google": "Continuar en Google",
|
||||
"Convert to ": "Convertir a",
|
||||
"Copied link to clipboard": "Link copiado!",
|
||||
"Copy": "Copiar",
|
||||
"Copy Link": "Copiar link",
|
||||
"Create": "Crear",
|
||||
"Create Or Import": "Crear o importar",
|
||||
"Create Shared Link Description": "Crear un link para compartir",
|
||||
"Create your own workspace": "Crea tu propio espacio de trabajo",
|
||||
"Date": "Fecha",
|
||||
"Date Format": "Formato de fecha",
|
||||
"Default Location": "Ubicación por defecto",
|
||||
"Default db location hint": "Será guardado por defecto en {{location}}",
|
||||
"Delete": "Borrar",
|
||||
"Delete Member?": "Borrar Miembro?",
|
||||
"Delete Workspace": "Borrar Espacio de Trabajo"
|
||||
}
|
||||
25
packages/frontend/i18n/src/resources/hi.json
Normal file
25
packages/frontend/i18n/src/resources/hi.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"404 - Page Not Found": "404 - पृष्ठ नहीं मिला",
|
||||
"AFFiNE Cloud": "एफ़िन क्लाउड",
|
||||
"AFFiNE Community": "एफ़िन समुदाय",
|
||||
"About AFFiNE": "एफ़िन के बारे में",
|
||||
"Access level": "पहुंच स्तर",
|
||||
"Add Workspace": "कार्यक्षेत्र जोड़ें",
|
||||
"Add Workspace Hint": "मौजूदा डेटाबेस फ़ाइल का चयन करें",
|
||||
"Add a subpage inside": "अंदर एक उपपृष्ठ जोड़ें",
|
||||
"Add to favorites": "पसंदीदा करने के लिए जोड़ें",
|
||||
"Added Successfully": "सफलतापूर्वक जोड़ा गया",
|
||||
"Added to Favorites": "पसंदीदा में जोड़ा गया",
|
||||
"All changes are saved locally": "सभी परिवर्तन स्थानीय रूप से सहेजे जाते हैं",
|
||||
"All data has been stored in the cloud": "सारा डेटा क्लाउड में संग्रहीत किया गया है",
|
||||
"All pages": "सभी पेज",
|
||||
"App Version": "एप्लिकेशन वेरीज़न",
|
||||
"Appearance Settings": "उपस्थिति सेटिंग्स",
|
||||
"Append to Daily Note": "दैनिक नोट में संलग्न करें",
|
||||
"Available Offline": "ऑफ़लाइन उपलब्ध है",
|
||||
"Back Home": "वापस घर",
|
||||
"Back to Quick Search": "त्वरित खोज पर वापस जाएँ",
|
||||
"Back to all": "सभी पर वापस जाएँ",
|
||||
"Body text": "मुख्य भाग",
|
||||
"Bold": "बोल्ड "
|
||||
}
|
||||
196
packages/frontend/i18n/src/resources/index.ts
Normal file
196
packages/frontend/i18n/src/resources/index.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import en from './en.json' with { type: 'json' };
|
||||
|
||||
export type Language =
|
||||
| 'en'
|
||||
| 'zh-Hans'
|
||||
| 'zh-Hant'
|
||||
| 'fr'
|
||||
| 'es'
|
||||
| 'es-AR'
|
||||
| 'es-CL'
|
||||
| 'pl'
|
||||
| 'de'
|
||||
| 'ru'
|
||||
| 'ja'
|
||||
| 'it'
|
||||
| 'ca'
|
||||
| 'da'
|
||||
| 'hi'
|
||||
| 'sv-SE'
|
||||
| 'ur'
|
||||
| 'ar'
|
||||
| 'uk'
|
||||
| 'ko'
|
||||
| 'pt-BR'
|
||||
| 'fa'
|
||||
| 'nb-NO'
|
||||
| 'kk'
|
||||
| 'tr';
|
||||
|
||||
export type LanguageResource = typeof en;
|
||||
export const SUPPORTED_LANGUAGES: Record<
|
||||
Language,
|
||||
{
|
||||
name: string;
|
||||
originalName: string;
|
||||
flagEmoji: string;
|
||||
rtl?: boolean;
|
||||
resource:
|
||||
| LanguageResource
|
||||
| (() => Promise<{ default: Partial<LanguageResource> }>);
|
||||
}
|
||||
> = {
|
||||
en: {
|
||||
name: 'English',
|
||||
originalName: 'English',
|
||||
flagEmoji: '🇬🇧',
|
||||
resource: en,
|
||||
},
|
||||
ko: {
|
||||
name: 'Korean (South Korea)',
|
||||
originalName: '한국어(대한민국)',
|
||||
flagEmoji: '🇰🇷',
|
||||
resource: () => import('./ko.json'),
|
||||
},
|
||||
'pt-BR': {
|
||||
name: 'Portuguese (Brazil)',
|
||||
originalName: 'português (Brasil)',
|
||||
flagEmoji: '🇧🇷',
|
||||
resource: () => import('./pt-BR.json'),
|
||||
},
|
||||
'zh-Hans': {
|
||||
name: 'Simplified Chinese',
|
||||
originalName: '简体中文',
|
||||
flagEmoji: '🇨🇳',
|
||||
resource: () => import('./zh-Hans.json'),
|
||||
},
|
||||
'zh-Hant': {
|
||||
name: 'Traditional Chinese',
|
||||
originalName: '繁體中文',
|
||||
flagEmoji: '🇭🇰',
|
||||
resource: () => import('./zh-Hant.json'),
|
||||
},
|
||||
fr: {
|
||||
name: 'French',
|
||||
originalName: 'français',
|
||||
flagEmoji: '🇫🇷',
|
||||
resource: () => import('./fr.json'),
|
||||
},
|
||||
es: {
|
||||
name: 'Spanish',
|
||||
originalName: 'español',
|
||||
flagEmoji: '🇪🇸',
|
||||
resource: () => import('./es.json'),
|
||||
},
|
||||
'es-AR': {
|
||||
name: 'Spanish (Argentina)',
|
||||
originalName: 'español (Argentina)',
|
||||
flagEmoji: '🇦🇷',
|
||||
resource: () => import('./es-AR.json'),
|
||||
},
|
||||
'es-CL': {
|
||||
name: 'Spanish (Chile)',
|
||||
originalName: 'español (Chile)',
|
||||
flagEmoji: '🇨🇱',
|
||||
resource: () => import('./es-CL.json'),
|
||||
},
|
||||
pl: {
|
||||
name: 'Polish',
|
||||
originalName: 'Polski',
|
||||
flagEmoji: '🇵🇱',
|
||||
resource: () => import('./pl.json'),
|
||||
},
|
||||
de: {
|
||||
name: 'German',
|
||||
originalName: 'Deutsch',
|
||||
flagEmoji: '🇩🇪',
|
||||
resource: () => import('./de.json'),
|
||||
},
|
||||
ru: {
|
||||
name: 'Russian',
|
||||
originalName: 'русский',
|
||||
flagEmoji: '🇷🇺',
|
||||
resource: () => import('./ru.json'),
|
||||
},
|
||||
ja: {
|
||||
name: 'Japanese',
|
||||
originalName: '日本語',
|
||||
flagEmoji: '🇯🇵',
|
||||
resource: () => import('./ja.json'),
|
||||
},
|
||||
it: {
|
||||
name: 'Italian',
|
||||
originalName: 'italiano',
|
||||
flagEmoji: '🇮🇹',
|
||||
resource: () => import('./it.json'),
|
||||
},
|
||||
ca: {
|
||||
name: 'Catalan',
|
||||
originalName: 'català',
|
||||
flagEmoji: '🇦🇩',
|
||||
resource: () => import('./ca.json'),
|
||||
},
|
||||
da: {
|
||||
name: 'Danish',
|
||||
originalName: 'dansk',
|
||||
flagEmoji: '🇩🇰',
|
||||
resource: () => import('./da.json'),
|
||||
},
|
||||
hi: {
|
||||
name: 'Hindi',
|
||||
originalName: 'हिन्दी',
|
||||
flagEmoji: '🇮🇳',
|
||||
resource: () => import('./hi.json'),
|
||||
},
|
||||
'sv-SE': {
|
||||
name: 'Swedish (Sweden)',
|
||||
originalName: 'svenska (Sverige)',
|
||||
flagEmoji: '🇸🇪',
|
||||
resource: () => import('./sv-SE.json'),
|
||||
},
|
||||
ur: {
|
||||
name: 'Urdu',
|
||||
originalName: 'اردو',
|
||||
flagEmoji: '🇵🇰',
|
||||
rtl: true,
|
||||
resource: () => import('./ur.json'),
|
||||
},
|
||||
ar: {
|
||||
name: 'Arabic',
|
||||
originalName: 'العربية',
|
||||
flagEmoji: '🇸🇦',
|
||||
rtl: true,
|
||||
resource: () => import('./ar.json'),
|
||||
},
|
||||
fa: {
|
||||
name: 'Persian',
|
||||
originalName: 'فارسی',
|
||||
flagEmoji: '🇮🇷',
|
||||
rtl: true,
|
||||
resource: () => import('./fa.json'),
|
||||
},
|
||||
uk: {
|
||||
name: 'Ukrainian',
|
||||
originalName: 'українська',
|
||||
flagEmoji: '🇺🇦',
|
||||
resource: () => import('./uk.json'),
|
||||
},
|
||||
'nb-NO': {
|
||||
name: 'Norwegian',
|
||||
originalName: 'Norsk (Bokmål)',
|
||||
flagEmoji: '🇳🇴',
|
||||
resource: () => import('./nb-NO.json'),
|
||||
},
|
||||
kk: {
|
||||
name: 'Kazakh',
|
||||
originalName: 'Қазақша',
|
||||
flagEmoji: '🇰🇿',
|
||||
resource: () => import('./kk.json'),
|
||||
},
|
||||
tr: {
|
||||
name: 'Turkish',
|
||||
originalName: 'Türkçe',
|
||||
flagEmoji: '🇹🇷',
|
||||
resource: () => import('./tr.json'),
|
||||
},
|
||||
};
|
||||
1073
packages/frontend/i18n/src/resources/nb-NO.json
Normal file
1073
packages/frontend/i18n/src/resources/nb-NO.json
Normal file
File diff suppressed because it is too large
Load Diff
404
packages/frontend/i18n/src/utils/__tests__/time.spec.ts
Normal file
404
packages/frontend/i18n/src/utils/__tests__/time.spec.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { getOrCreateI18n, I18n } from '../../';
|
||||
import { i18nTime } from '../time';
|
||||
|
||||
// Intl api is not available in github action, skip the test
|
||||
describe('humanTime', () => {
|
||||
test('absolute', async () => {
|
||||
getOrCreateI18n();
|
||||
expect(i18nTime('2024-10-10 13:30:28')).toBe('Oct 10, 2024, 1:30:28 PM');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28', {
|
||||
absolute: {
|
||||
accuracy: 'minute',
|
||||
},
|
||||
})
|
||||
).toBe('Oct 10, 2024, 1:30 PM');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28', {
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('Oct 10, 2024');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28', {
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
noYear: true,
|
||||
},
|
||||
})
|
||||
).toBe('Oct 10');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28', {
|
||||
absolute: {
|
||||
accuracy: 'year',
|
||||
},
|
||||
})
|
||||
).toBe('2024');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28', {
|
||||
absolute: {
|
||||
noDate: true,
|
||||
accuracy: 'minute',
|
||||
},
|
||||
})
|
||||
).toBe('1:30 PM');
|
||||
});
|
||||
|
||||
test('relative', async () => {
|
||||
getOrCreateI18n();
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28.005', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('1s ago');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:25:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('5m ago');
|
||||
expect(
|
||||
i18nTime('2024-10-10 12:59:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('31m ago');
|
||||
expect(
|
||||
i18nTime('2024-10-10 12:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('1h ago');
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('yesterday');
|
||||
expect(
|
||||
i18nTime('2024-10-9 12:30:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('yesterday');
|
||||
expect(
|
||||
i18nTime('2024-10-8 23:59:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('2d ago');
|
||||
expect(
|
||||
i18nTime('2024-10-7 23:59:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('3d ago');
|
||||
expect(
|
||||
i18nTime('2024-10-4 00:00:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('6d ago');
|
||||
expect(
|
||||
i18nTime('2024-10-3 23:59:59', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('last wk.');
|
||||
expect(
|
||||
i18nTime('2024-9-29 23:59:59', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('last wk.');
|
||||
expect(
|
||||
i18nTime('2024-9-28 23:59:59', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('2w ago');
|
||||
expect(
|
||||
i18nTime('2024-9-15 00:00:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('last mo.');
|
||||
expect(
|
||||
i18nTime('2024-9-1 00:00:00', {
|
||||
now: '2024-9-30 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('4w ago');
|
||||
expect(
|
||||
i18nTime('2024-9-10 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('last mo.');
|
||||
expect(
|
||||
i18nTime('2023-9-10 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('last yr.');
|
||||
});
|
||||
|
||||
test('relative - accuracy', async () => {
|
||||
getOrCreateI18n();
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28.005', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'minute',
|
||||
},
|
||||
})
|
||||
).toBe('now');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:25:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'minute',
|
||||
},
|
||||
})
|
||||
).toBe('5m ago');
|
||||
expect(
|
||||
i18nTime('2024-10-10 12:59:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'hour',
|
||||
},
|
||||
})
|
||||
).toBe('now');
|
||||
expect(
|
||||
i18nTime('2024-10-10 12:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('today');
|
||||
expect(
|
||||
i18nTime('2024-10-4 00:00:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'week',
|
||||
},
|
||||
})
|
||||
).toBe('last wk.');
|
||||
expect(
|
||||
i18nTime('2024-10-9 00:00:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'week',
|
||||
},
|
||||
})
|
||||
).toBe('this week');
|
||||
expect(
|
||||
i18nTime('2024-9-1 00:00:00', {
|
||||
now: '2024-9-30 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'month',
|
||||
},
|
||||
})
|
||||
).toBe('this month');
|
||||
expect(
|
||||
i18nTime('2024-9-10 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'year',
|
||||
},
|
||||
})
|
||||
).toBe('this year');
|
||||
expect(
|
||||
i18nTime('2023-9-10 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'year',
|
||||
},
|
||||
})
|
||||
).toBe('last yr.');
|
||||
});
|
||||
|
||||
test('relative - disable yesterdayAndTomorrow', async () => {
|
||||
getOrCreateI18n();
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('1d ago');
|
||||
expect(
|
||||
i18nTime('2024-10-11 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('in 1d');
|
||||
});
|
||||
|
||||
test('relative - weekday', async () => {
|
||||
getOrCreateI18n();
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('Wednesday');
|
||||
expect(
|
||||
i18nTime('2024-10-4 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('Friday');
|
||||
expect(
|
||||
i18nTime('2024-10-3 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('1w ago');
|
||||
expect(
|
||||
i18nTime('2024-10-11 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('Friday');
|
||||
expect(
|
||||
i18nTime('2024-10-16 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('Wednesday');
|
||||
expect(
|
||||
i18nTime('2024-10-17 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('in 1w');
|
||||
});
|
||||
|
||||
test('mix relative and absolute', async () => {
|
||||
getOrCreateI18n();
|
||||
expect(
|
||||
i18nTime('2024-10-9 14:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
max: [1, 'day'],
|
||||
},
|
||||
})
|
||||
).toBe('23h ago');
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
max: [1, 'day'],
|
||||
},
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('Oct 9, 2024');
|
||||
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
max: [2, 'day'],
|
||||
},
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('yesterday');
|
||||
|
||||
expect(
|
||||
i18nTime('2024-10-8 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
max: [2, 'day'],
|
||||
},
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('Oct 8, 2024');
|
||||
});
|
||||
|
||||
test('chinese', async () => {
|
||||
getOrCreateI18n();
|
||||
await I18n.changeLanguage('zh-Hans');
|
||||
expect(i18nTime('2024-10-10 13:30:28.005')).toBe('2024年10月10日 13:30:28');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28.005', {
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('2024年10月10日');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28.005', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('1秒前');
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('昨天');
|
||||
expect(
|
||||
i18nTime('2024-10-8 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
},
|
||||
})
|
||||
).toBe('星期二');
|
||||
expect(
|
||||
i18nTime('2024-10-8 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'week',
|
||||
},
|
||||
})
|
||||
).toBe('本周');
|
||||
expect(
|
||||
i18nTime('2024-10-8 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'month',
|
||||
},
|
||||
})
|
||||
).toBe('本月');
|
||||
});
|
||||
|
||||
test('invalid time', () => {
|
||||
getOrCreateI18n();
|
||||
expect(i18nTime('foobar')).toBe('');
|
||||
});
|
||||
});
|
||||
1
packages/frontend/i18n/src/utils/index.ts
Normal file
1
packages/frontend/i18n/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { i18nTime } from './time';
|
||||
285
packages/frontend/i18n/src/utils/time.ts
Normal file
285
packages/frontend/i18n/src/utils/time.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { I18n, type I18nInstance } from '../i18next';
|
||||
|
||||
export type TimeUnit =
|
||||
| 'second'
|
||||
| 'minute'
|
||||
| 'hour'
|
||||
| 'day'
|
||||
| 'week'
|
||||
| 'month'
|
||||
| 'year';
|
||||
|
||||
const timeUnitCode = {
|
||||
second: 1,
|
||||
minute: 2,
|
||||
hour: 3,
|
||||
day: 4,
|
||||
week: 5,
|
||||
month: 6,
|
||||
year: 7,
|
||||
} satisfies Record<TimeUnit, number>;
|
||||
|
||||
/**
|
||||
* ```ts
|
||||
* // timestamp to string
|
||||
* i18nTime(1728538228000) -> 'Oct 10, 2024, 1:30:28 PM'
|
||||
*
|
||||
* // absolute time string
|
||||
* i18nTime('2024-10-10 13:30:28', { absolute: { accuracy: 'minute' } }) -> '2024-10-10 13:30 PM'
|
||||
* i18nTime('2024-10-10 13:30:28', { absolute: { accuracy: 'minute', noDate: true } }) -> '13:30 PM'
|
||||
* i18nTime('2024-10-10 13:30:28', { absolute: { accuracy: 'minute', noYear: true } }) -> 'Oct 10, 13:30 PM'
|
||||
* i18nTime('2024-10-10 13:30:28', { absolute: { accuracy: 'day' } }) -> 'Oct 10, 2024'
|
||||
*
|
||||
* // relative time string
|
||||
* i18nTime('2024-10-10 13:30:30', { relative: true }) -> 'now'
|
||||
* i18nTime('2024-10-10 13:30:00', { relative: true }) -> '30s ago'
|
||||
* i18nTime('2024-10-10 13:30:30', { relative: { accuracy: 'minute' } }) -> '2m ago'
|
||||
*
|
||||
* // show absolute time string if time is pass 1 day
|
||||
* i18nTime('2024-10-9 14:30:30', { relative: { max: [1, 'day'] } }) -> '23h ago'
|
||||
* i18nTime('2024-10-9 13:30:30', { relative: { max: [1, 'day'] } }) -> 'Oct 9, 2024, 1:30:30 PM'
|
||||
* ```
|
||||
*/
|
||||
export function i18nTime(
|
||||
time: dayjs.ConfigType,
|
||||
options: {
|
||||
// override i18n instance, default is global I18n instance
|
||||
i18n?: I18nInstance;
|
||||
// override now time, default is current time
|
||||
now?: dayjs.ConfigType;
|
||||
relative?:
|
||||
| {
|
||||
// max time to show relative time, if time is pass this time, show absolute time
|
||||
max?: [number, TimeUnit];
|
||||
// show time with this accuracy
|
||||
accuracy?: TimeUnit;
|
||||
// show weekday, e.g. 'Monday', 'Tuesday', etc.
|
||||
weekday?: boolean;
|
||||
// show 'yesterday' or 'tomorrow' if time is
|
||||
yesterdayAndTomorrow?: boolean;
|
||||
}
|
||||
| true; // use default relative option
|
||||
absolute?: {
|
||||
// show time with this accuracy
|
||||
accuracy?: TimeUnit;
|
||||
// hide year
|
||||
noYear?: boolean;
|
||||
// hide date (year, month, day)
|
||||
noDate?: boolean;
|
||||
};
|
||||
} = {}
|
||||
) {
|
||||
const i18n = options.i18n ?? I18n;
|
||||
time = dayjs(time);
|
||||
if (!time.isValid()) {
|
||||
return ''; // invalid time, return empty string
|
||||
}
|
||||
|
||||
const now = dayjs(options.now);
|
||||
|
||||
const defaultRelativeOption = {
|
||||
max: [1000, 'year'],
|
||||
accuracy: 'second',
|
||||
weekday: false,
|
||||
yesterdayAndTomorrow: true,
|
||||
} satisfies typeof options.relative;
|
||||
|
||||
const relativeOption = options.relative
|
||||
? options.relative === true
|
||||
? defaultRelativeOption
|
||||
: {
|
||||
...defaultRelativeOption,
|
||||
...options.relative,
|
||||
}
|
||||
: null;
|
||||
|
||||
const defaultAbsoluteOption = {
|
||||
accuracy: 'second',
|
||||
noYear: false,
|
||||
noDate: false,
|
||||
} satisfies typeof options.absolute;
|
||||
|
||||
const absoluteOption = {
|
||||
...defaultAbsoluteOption,
|
||||
...options.absolute,
|
||||
};
|
||||
|
||||
if (relativeOption) {
|
||||
// show relative
|
||||
|
||||
const formatter = new Intl.RelativeTimeFormat(i18n.language, {
|
||||
style: 'narrow',
|
||||
numeric: relativeOption.yesterdayAndTomorrow ? 'auto' : 'always',
|
||||
});
|
||||
|
||||
const timeUnitProcessor = {
|
||||
second: () => {
|
||||
const diffSecond = time.diff(now) / 1000;
|
||||
if (Math.abs(diffSecond) < 1) {
|
||||
return i18n['com.affine.time.now']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'second' &&
|
||||
Math.abs(diffSecond) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(diffSecond) < 60) {
|
||||
return formatter.format(Math.trunc(diffSecond), 'second');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
minute: () => {
|
||||
const diffMinute = time.diff(now) / 1000 / 60;
|
||||
if (Math.abs(diffMinute) < 1) {
|
||||
return i18n['com.affine.time.now']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'minute' &&
|
||||
Math.abs(diffMinute) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(diffMinute) < 60) {
|
||||
return formatter.format(Math.trunc(diffMinute), 'minute');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
hour: () => {
|
||||
const diffHour = time.diff(now) / 1000 / 60 / 60;
|
||||
if (Math.abs(diffHour) < 1) {
|
||||
return i18n['com.affine.time.now']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'hour' &&
|
||||
Math.abs(diffHour) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(diffHour) < 24) {
|
||||
return formatter.format(Math.trunc(diffHour), 'hour');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
day: () => {
|
||||
const diffDay = time.startOf('day').diff(now.startOf('day'), 'day');
|
||||
if (Math.abs(diffDay) < 1) {
|
||||
return i18n['com.affine.time.today']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'day' &&
|
||||
Math.abs(diffDay) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (relativeOption.yesterdayAndTomorrow && Math.abs(diffDay) < 2) {
|
||||
return formatter.format(Math.trunc(diffDay), 'day');
|
||||
} else if (relativeOption.weekday && Math.abs(diffDay) < 7) {
|
||||
return new Intl.DateTimeFormat(i18n.language, {
|
||||
weekday: 'long',
|
||||
}).format(time.startOf('day').toDate());
|
||||
} else if (Math.abs(diffDay) < 7) {
|
||||
return formatter.format(Math.trunc(diffDay), 'day');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
week: () => {
|
||||
const inSameMonth = time.startOf('month').isSame(now.startOf('month'));
|
||||
const diffWeek = time.startOf('week').diff(now.startOf('week'), 'week');
|
||||
if (Math.abs(diffWeek) < 1) {
|
||||
return i18n['com.affine.time.this-week']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'week' &&
|
||||
Math.abs(diffWeek) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (inSameMonth || Math.abs(diffWeek) < 3) {
|
||||
return formatter.format(Math.trunc(diffWeek), 'week');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
month: () => {
|
||||
const diffMonth = time
|
||||
.startOf('month')
|
||||
.diff(now.startOf('month'), 'month');
|
||||
if (Math.abs(diffMonth) < 1) {
|
||||
return i18n['com.affine.time.this-mouth']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'month' &&
|
||||
Math.abs(diffMonth) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(diffMonth) < 12) {
|
||||
return formatter.format(Math.trunc(diffMonth), 'month');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
year: () => {
|
||||
const diffYear = time.startOf('year').diff(now.startOf('year'), 'year');
|
||||
if (Math.abs(diffYear) < 1) {
|
||||
return i18n['com.affine.time.this-year']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'year' &&
|
||||
Math.abs(diffYear) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return formatter.format(Math.trunc(diffYear), 'year');
|
||||
},
|
||||
} as Record<TimeUnit, () => string | false | null>;
|
||||
|
||||
const processors = Object.entries(timeUnitProcessor).sort(
|
||||
(a, b) => timeUnitCode[a[0] as TimeUnit] - timeUnitCode[b[0] as TimeUnit]
|
||||
) as [TimeUnit, () => string | false | null][];
|
||||
|
||||
for (const [unit, processor] of processors) {
|
||||
if (timeUnitCode[relativeOption.accuracy] > timeUnitCode[unit]) {
|
||||
continue;
|
||||
}
|
||||
const result = processor();
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
if (result === false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// show absolute
|
||||
const formatter = new Intl.DateTimeFormat(i18n.language, {
|
||||
year:
|
||||
!absoluteOption.noYear && !absoluteOption.noDate ? 'numeric' : undefined,
|
||||
month:
|
||||
!absoluteOption.noDate &&
|
||||
timeUnitCode[absoluteOption.accuracy] <= timeUnitCode['month']
|
||||
? 'short'
|
||||
: undefined,
|
||||
day:
|
||||
!absoluteOption.noDate &&
|
||||
timeUnitCode[absoluteOption.accuracy] <= timeUnitCode['day']
|
||||
? 'numeric'
|
||||
: undefined,
|
||||
hour:
|
||||
timeUnitCode[absoluteOption.accuracy] <= timeUnitCode['hour']
|
||||
? 'numeric'
|
||||
: undefined,
|
||||
minute:
|
||||
timeUnitCode[absoluteOption.accuracy] <= timeUnitCode['minute']
|
||||
? 'numeric'
|
||||
: undefined,
|
||||
second:
|
||||
timeUnitCode[absoluteOption.accuracy] <= timeUnitCode['second']
|
||||
? 'numeric'
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return formatter.format(time.toDate());
|
||||
}
|
||||
18
packages/frontend/i18n/tsconfig.json
Normal file
18
packages/frontend/i18n/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.web.json",
|
||||
"include": [
|
||||
"./src",
|
||||
"./src/resources/*.json",
|
||||
"./src/i18n-completenesses.json"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../../common/debug" },
|
||||
{ "path": "../../../tools/cli" },
|
||||
{ "path": "../../../tools/utils" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user