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

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

2
packages/frontend/i18n/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
lib
i18n-generated.ts

View 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
}
}
]
}

View 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/)

View 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();

View 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)
);

View 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"
}
}

View 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
}

View 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;

View 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;

View 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';

View 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"
}

View 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"
}

View 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"
}

View 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": "बोल्ड "
}

View 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'),
},
};

File diff suppressed because it is too large Load Diff

View 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('');
});
});

View File

@@ -0,0 +1 @@
export { i18nTime } from './time';

View 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());
}

View 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" }
]
}