Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ COPY --from=prod-deps /src/packages/mcp/node_modules ./packages/mcp/node_modules
COPY --from=build /src/package.json ./package.json
COPY --from=build /src/apps/server/package.json ./apps/server/package.json
COPY --from=build /src/apps/server/dist ./apps/server/dist
# The inventory report's fonts (D201): eight TTFs the server build converts from the @ibm/plex-*
# The inventory report's fonts (D201): eleven TTFs the server build converts from the @ibm/plex-*
# devDependencies, with their OFL texts. dist/ already holds the report's child renderer and
# Typst template beside the modules that load them (src/reports/render/build-assets.ts).
COPY --from=build /src/apps/server/assets ./apps/server/assets
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ Every feature, with its page: [all features](https://keptinventory.info/users/fe
CSV, full exports.
- **Your own network or HTTPS.** Works over plain http at home, with sign-ins as long as you set
and more than one address; an export with secret values needs HTTPS.
- **Languages:** Arabic (right to left), English, French, German, Italian, Spanish, Ukrainian and
Vietnamese; type to find yours, and amounts are read as your language writes them. The user,
admin and install docs are in every language too.
- **Languages:** Arabic (right to left), English, French, German, Italian, Simplified Chinese,
Spanish, Ukrainian and Vietnamese; type to find yours, and amounts are read as your language
writes them. The user, admin and install docs are in eight of those languages.

<p align="center">
<img src="docs/assets/claude-mcp.webp" alt="Claude Code connected to Kept over MCP: asked to add a TV with its serial number, it puts it in Home › Living room" width="720">
Expand Down
6 changes: 3 additions & 3 deletions apps/docs/src/content/docs/developers/i18n-rtl.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ title: Languages and right-to-left
description: How strings are translated with Lingui, what the catalogue checks enforce, and the rules that keep Arabic correct.
---

Kept ships in Arabic, English, French, German, Italian (D204), Spanish, Ukrainian and
Vietnamese.
Kept ships in Arabic, English, French, German, Italian (D204), Simplified Chinese, Spanish,
Ukrainian and Vietnamese.
Arabic is right-to-left, so every screen has to mirror without per-screen overrides. The web app
uses [Lingui](https://lingui.dev/) with PO catalogues (D96); the server has its own small typed
tables for mail and reports.

## Lingui in the web app

`apps/web/lingui.config.ts` sets the source locale `en`, the locales `ar`, `de`, `en`, `es`, `fr`, `it`,
`uk`, `vi`, and one catalogue per locale at `apps/web/src/locales/<locale>/messages.po`, extracted from
`uk`, `vi`, `zh`, and one catalogue per locale at `apps/web/src/locales/<locale>/messages.po`, extracted from
`src` (tests and `routeTree.gen.ts` excluded). Catalogues have no line numbers, so they don't
churn on every edit.

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/src/content/docs/users/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ no Kept needed. To bring Kept itself back on a new machine you also need the rec

## Which languages does it speak?

Arabic (laid out right to left), English, French, German, Italian, Spanish, Ukrainian and
Vietnamese, chosen per device. See [language and theme](/users/first-run/#6-language-and-theme).
Arabic (laid out right to left), English, French, German, Italian, Simplified Chinese, Spanish,
Ukrainian and Vietnamese, chosen per device. See [language and theme](/users/first-run/#6-language-and-theme).

## What licence is it under?

Expand Down
6 changes: 3 additions & 3 deletions apps/docs/src/content/docs/users/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ a location turns them on in **Location settings → What to track**
network over plain http, [sign-ins as long as the admin sets](/install/home-network/#staying-on-your-network);
one Kept on [more than one address](/install/home-network/#more-than-one-address), such as a LAN
name and a Tailscale name.
- **Languages**: Arabic (right to left), English, French, German, Italian, Spanish, Ukrainian
and Vietnamese; type to find yours in the picker, and dates in your region's style, or as you choose.
- **Languages**: Arabic (right to left), English, French, German, Italian, Simplified Chinese,
Spanish, Ukrainian and Vietnamese; type to find yours in the picker, and dates in your region's style, or as you choose.
Amounts are read as your language writes them. [PDFs](/users/reports/), claim packs and the readable copy
come out in your language. The user, admin and install docs are in every language too.
come out in your language. The user, admin and install docs are in eight of those languages.

## On your phone

Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"@ibm/plex-mono": "2.5.0",
"@ibm/plex-sans": "1.1.0",
"@ibm/plex-sans-arabic": "1.1.0",
"@ibm/plex-sans-sc": "1.1.0",
"@modelcontextprotocol/client": "2.3.1",
"@types/html-to-text": "9.0.4",
"@types/mailparser": "3.9.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/accounts/me.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ describe('GET /api/v1/me', () => {
).rowCount,
).toBe(1);

for (const locale of ['ar', 'de', 'en', 'es', 'fr', 'it', 'uk', 'vi']) {
for (const locale of ['ar', 'de', 'en', 'es', 'fr', 'it', 'uk', 'vi', 'zh']) {
const ok = await call(t, '/api/v1/me', { as: me, method: 'PATCH', body: { locale } });
expect(ok.statusCode, locale).toBe(200);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/assistant/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('the tools as the model sees them', () => {

describe('the instructions', () => {
it("answer in each of Kept's languages", () => {
expect(['ar', 'de', 'en', 'es', 'fr', 'it', 'uk', 'vi'].map(languageOf)).toEqual([
expect(['ar', 'de', 'en', 'es', 'fr', 'it', 'uk', 'vi', 'zh'].map(languageOf)).toEqual([
'Arabic',
'German',
'English',
Expand All @@ -100,6 +100,7 @@ describe('the instructions', () => {
'Italian',
'Ukrainian',
'Vietnamese',
'Simplified Chinese',
]);
});

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/assistant/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const LANGUAGES: Record<string, string> = {
it: 'Italian',
uk: 'Ukrainian',
vi: 'Vietnamese',
zh: 'Simplified Chinese',
};

export type PromptLocation = { id: string; name: string; role: Role; canWrite: boolean };
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/assistant/select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,15 @@ describe('choosing the tools a step offers', () => {
expect(offered('Abre una reclamación'.normalize('NFD'))).toContain('open_claim');
});

it('matches Simplified Chinese questions without word spaces', () => {
expect(offered('把电钻借给 Louis 了')).toContain('lend_thing');
expect(offered('洗衣机的说明书和发票在哪')).toContain('find_documents');
expect(offered('卧室 4米乘3米')).toContain('create_place');
expect(offered('电池库存还剩多少')).toContain('adjust_stock');
expect(offered('汽车上次加油和保养记录')).toContain('log_fuel');
expect(offered('螺丝刀放在哪里?')).toEqual(offered('Are you working?'));
});

it('keeps a tool the thread already used, and one the model asked for and was not offered', () => {
expect(offered('and the drill?', ['lend_thing'])).toContain('lend_thing');
expect(offered('and the drill?', ['start_move'])).not.toContain('start_move');
Expand Down
21 changes: 15 additions & 6 deletions apps/server/src/assistant/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const CORE_TOOLS: readonly ToolName[] = Object.freeze([
'mark_seen',
]);

type Lang = 'ar' | 'de' | 'en' | 'es' | 'fr' | 'it' | 'uk' | 'vi';
type Lang = 'ar' | 'de' | 'en' | 'es' | 'fr' | 'it' | 'uk' | 'vi' | 'zh';
type Topic = { tools: readonly ToolName[]; words: Readonly<Record<Lang, string>> };

/** Keywords per topic and language, `|` between them, written plainly: `normalise` (or, for
Expand All @@ -60,6 +60,7 @@ const TOPICS: Readonly<Record<string, Topic>> = {
it: 'cronologia|storia|successo|chi ha',
uk: 'історі|сталос|трапил|хто перемі|хто перест|хто перен|хто пересун|хто змін|хто дода|коли$|востаннє|останній раз|минулого разу',
vi: 'lịch sử|đã xảy ra|chuyện gì|ai đã|ai chuyển|ai sửa|ai thêm|khi nào|lần cuối',
zh: '历史|记录|发生了什么|谁动过|谁移|谁改|谁加|什么时候|上次',
},
},
documents: {
Expand All @@ -73,6 +74,7 @@ const TOPICS: Readonly<Record<string, Topic>> = {
it: 'ricevuta|scontrino|fattura|manuale|documento|istruzioni',
uk: 'квитанці|чек$|чека$|чеку$|чеки$|чеків$|чеком$|рахунок|рахунк|фактур|інструкці|посібник|керівництв|документ|папери|паперів|пдф',
vi: 'hóa đơn|hoá đơn|biên lai|tài liệu|giấy tờ|chứng từ|hướng dẫn sử dụng|sách hướng dẫn',
zh: '收据|小票|发票|说明书|使用手册|手册|文档|证件|凭证',
},
},
// #313: changing a place (its name, kind, parent, order) and turning places into boxes.
Expand All @@ -88,6 +90,7 @@ const TOPICS: Readonly<Record<string, Topic>> = {
it: 'stanza|camera da letto|bagno|cucina|corridoio|soffitta|cantina|scaffal|cassetto|armadio|crea|collegamento|rinomin|in scatol|ordine|girare|giralo|girala|gira il|gira la|gira lo|ruotare|ruotalo|ruotala|di lato|sul fianco|mobil|libreria',
uk: 'кімната|кімнати|кімнату|кімнаті|кімнатою|кімнат$|спальня|спальню|спальні|спальнею|спалень|ванна|ванну|ванні|ванною|кухня|кухні|кухню|кухнею|вітальня|вітальні|вітальню|вітальнею|коридор|передпок|балкон|лоджі|горищ|підвал|льох|комор|полиц|поличк|поличц|шухляд|шафа|шафи|шафу|шафі|шафою|шаф$|шафк|шафц|комод|стелаж|гардероб|мебл|зона|зони|зону|зоні|зоною|місце$|місця$|місці$|місцем$|місць$|місцях$|приміщен|створ|фото$|фотограф|фотк|фоток|сфотограф|знімок|знімк|зображен|картинк|картинц|скану|відскан|посилан|лінк$|лінка$|лінку$|лінки$|прикріп|переймену|на коробк|на ящик|у коробк|в коробк|у ящик|в ящик|порядок$|власному порядку|упорядк|впорядк|перемісти вгору|перемісти вниз|перемісти вище|перемісти нижче|посунь вище|посунь нижче|розверн|розверт|оберн|оберт|на бік|набік|на боку|вертикально$|сторч|габарит',
vi: 'vị trí|phòng|kệ|giá sách|tủ quần áo|tủ sách|tủ đồ|ngăn kéo|nội thất|tạo|chụp|chụp ảnh|hình ảnh|quét|liên kết|đính kèm|đổi tên|thành hộp|thứ tự|xoay|nằm nghiêng|dựng đứng',
zh: '房间|卧室|厨房|卫生间|浴室|客厅|走廊|阳台|阁楼|地下室|置物架|书架|抽屉|橱柜|衣柜|书柜|家具|新建位置|创建房间|拍照|照片|扫码|链接|重命名|转为收纳箱|改成箱子|排序|上移|下移|旋转|侧放|横放|竖放|立起来',
},
},
schedules: {
Expand All @@ -101,6 +104,7 @@ const TOPICS: Readonly<Record<string, Topic>> = {
it: 'scadenz|scade|promemoria|manutenzion|presto|prossim|fatto|rimand|posticip|compito',
uk: 'термін$|терміну$|терміни$|терміном$|простроч|розклад$|розкладу$|розкладі$|розкладом$|графік$|графіку$|графіки$|графіком$|нагад|обслуговуван|найближч|наступн|скоро$|незабаром|невдовзі|сплив|закінчується|закінчиться|зроблено|викона|готово$|відклад|відклас|відстроч|пізніше|завдан|задач|цього тижня|цей тиждень|цьому тижні|цього місяця|цей місяць|цьому місяці',
vi: 'lịch trình|lịch bảo dưỡng|lịch hẹn|bảo dưỡng|bảo trì|đến hạn|quá hạn|nhắc|sắp tới|hết hạn|tiếp theo|xong|hoãn|dời|để sau|tác vụ|việc cần làm|tuần này|tháng này',
zh: '到期|逾期|过期|日程|计划|提醒|维护|即将|下一次|已完成|推迟|延后|待办|本周|这个月|本月',
},
},
lending: {
Expand All @@ -114,6 +118,7 @@ const TOPICS: Readonly<Record<string, Topic>> = {
it: 'prest|restitu|ridat|riavut|tornat|consegn|affidat',
uk: 'позич|запозич|одолж|борг|дав$|дала$|дали$|віддав|віддала$|віддали$|віддай|передав$|передала|передали|передати$|передай|передано|поверн|верну|назад$|забра|у кого|в кого|хто взяв|хто взяла|хто має|взяв у$|взяла у$|взяли у$|взяв в$|взяла в$|взяли в$',
vi: 'cho mượn|mượn|trả lại|đã trả|trả cho|trả về|lấy lại|nhận lại|ai đang giữ|ai giữ|đang giữ|bàn giao|giao cho',
zh: '借出|借给|借走|借用|借入|归还|还回|还给|拿回|在谁那|谁拿了|交接',
},
},
warranties: {
Expand All @@ -127,6 +132,7 @@ const TOPICS: Readonly<Record<string, Topic>> = {
it: 'garanzia|reclamo|riparazion|rotto|guasto',
uk: 'гаранті|рекламаці|претензі|страхов|ремонт|відремонт|полагод|лагод|злама|полама|розбит|розбив|розбил|несправн|дефект|не працює',
vi: 'bảo hành|yêu cầu bảo hành|sửa chữa|đi sửa|hỏng|hư|đổi trả',
zh: '保修|质保|联保|理赔|报修|维修|修理|坏了|故障|返修',
},
},
vehicles: {
Expand All @@ -140,6 +146,7 @@ const TOPICS: Readonly<Record<string, Topic>> = {
it: 'macchina|veicolo|tagliando|olio|gomm|chilometr|contachilometri|benzina|carburante|gasolio|pieno|lettura|contatore',
uk: 'автомобіл|авто$|автівк|автівц|транспортн|моя машин|мою машин|моєї машин|моїй машин|наша машин|нашу машин|нашої машин|на машині$|у машині$|в машині$|мотоцикл|скутер|мопед|велосипед|байк$|сервіс|техогляд|техобслугов|заміна масла|заміну масла|заміни масла|замінив масло|замінила масло|замінили масло|моторн|мастил|шина|шини|шину|шині|шин$|покришк|покришок|шиномонтаж|пробіг|одометр|спідометр|кілометр|км$|миль$|пальне|пального|пальним|пальному|бензин|бензобак|дизел|солярк|заправ|повний бак|лічильник|показання|показань|показник|мотогодин|електроенерг|кіловат|квт',
vi: 'xe|ô tô|phương tiện|bảo dưỡng|thay dầu|dầu|nhớt|lốp|số km|cây số|công tơ|công-tơ-mét|nhiên liệu|xăng|đổ xăng|đổ đầy|chỉ số|đồng hồ đo|đồng hồ điện|điện năng|kwh',
zh: '汽车|轿车|车辆|摩托|自行车|保养|机油|轮胎|里程|公里|汽油|柴油|加油|加满|充电|油箱|读数|表计|电表',
},
},
// #209: the tags themselves; tagging one thing is update_thing's, in the core.
Expand All @@ -154,6 +161,7 @@ const TOPICS: Readonly<Record<string, Topic>> = {
it: 'tag|etichetta',
uk: 'тег|протег|мітк|мітц|міток|ярлик|ярлич',
vi: 'gắn thẻ|bỏ thẻ|các thẻ|thẻ nào|thẻ này',
zh: '标签|打标签|取消标签',
},
},
stock: {
Expand All @@ -167,6 +175,7 @@ const TOPICS: Readonly<Record<string, Topic>> = {
it: 'scorta|scorte|rimast|esaurit|consumabil|usat|finit',
uk: 'запас$|запасу$|запаси$|запасів$|запасом$|запасах$|припас|залишилос|залишилас|залишилис|залишився|залишок|залишк|лишилос|лишилас|лишилис|лишився|закінч|скінч|вичерп|більше немає|немає більше|використав|використала|використали|використано|поповн|докуп|витратн|кількіст|кількост',
vi: 'tồn kho|còn lại|còn bao nhiêu|còn mấy|sắp hết|dùng hết|hết rồi|vật tư|tiêu hao|bổ sung|nạp thêm|mua thêm|số lượng',
zh: '库存|余量|还剩|用完|耗尽|快没了|补货|补充|耗材|数量',
},
},
};
Expand Down Expand Up @@ -199,21 +208,21 @@ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const atStart = (w: string) =>
w.endsWith('$') ? `${escapeRe(w.slice(0, -1))}(?![\\p{L}\\p{N}])` : escapeRe(w);

/** Two patterns per topic: Latin and Cyrillic keywords at a word's start and Arabic ones anywhere,
* on `normalise`d text; Vietnamese ones as whole words, on `normaliseVi`d text. */
/** Two patterns per topic: Latin and Cyrillic keywords at a word's start and Arabic or Chinese
* ones anywhere, on `normalise`d text; Vietnamese ones as whole words, on `normaliseVi`d text. */
const PATTERNS: readonly [Topic, RegExp, RegExp][] = Object.values(TOPICS).map((topic) => {
const list = (langs: Lang[], fold: (s: string) => string, source = escapeRe) =>
langs.flatMap((l) => topic.words[l].split('|')).map((w) => source(fold(w)));
const re = `(?<![\\p{L}\\p{N}])(?:${list(['de', 'en', 'es', 'fr', 'it', 'uk'], normalise, atStart).join('|')})|${list(['ar'], normalise).join('|')}`;
const re = `(?<![\\p{L}\\p{N}])(?:${list(['de', 'en', 'es', 'fr', 'it', 'uk'], normalise, atStart).join('|')})|${list(['ar', 'zh'], normalise).join('|')}`;
const vi = `(?<![\\p{L}\\p{N}])(?:${list(['vi'], normaliseVi).join('|')})(?![\\p{L}\\p{N}])`;
return [topic, new RegExp(re, 'u'), new RegExp(vi, 'u')];
});

/** A size said as two numbers, "4 by 3", "2.5 x 2 m", "4 sur 3", "٤ في ٣", "4 por 3", "4 на 3" (or
* "4х3", the Cyrillic letter): a room with its size (#372). Words such as "size" or "metre" alone say too
* "4х3", the Cyrillic letter), "4乘3": a room with its size (#372). Words such as "size" or "metre" alone say too
* little (a TV's size, a tape measure). */
const DIMENSIONS =
/\p{Nd}+(?:[.,٫]\p{Nd}+)?\s*(?:cm|m|metres?|meters?|metri|metros?|meter|см|м|метр(?:и|а|ів)?)?\s*(?:x|×|by|sur|per|por|mal|في|на|х)\s*\p{Nd}/u;
/\p{Nd}+(?:[.,٫]\p{Nd}+)?\s*(?:cm|m|metres?|meters?|metri|metros?|meter|см|м|метр(?:и|а|ів)?|厘米|米)?\s*(?:x|×|by|sur|per|por|mal|في|на|х|乘)\s*\p{Nd}/u;

/** The tools of every topic `text` names. */
function topicTools(text: string): Set<ToolName> {
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/assistant/words.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { noAnswer, viewerRefusal, wordsLocale } from './words.js';

// The assistant's fixed sentences: every interface language has its own.

const ALL = ['ar', 'de', 'en', 'es', 'fr', 'it', 'uk', 'vi'] as const;
const ALL = ['ar', 'de', 'en', 'es', 'fr', 'it', 'uk', 'vi', 'zh'] as const;

describe('wordsLocale()', () => {
it.each([
['es-MX', 'es'],
['uk-UA', 'uk'],
['vi', 'vi'],
['zh-CN', 'zh'],
['fr-CA', 'fr'],
['ar', 'ar'],
['pt-BR', 'en'],
Expand Down
Loading
Loading