@things-factory/shell 10.1.150 → 10.1.155

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ import { Domain } from '../service/domain/domain.js';
2
+ /** The domain's id followed by its ancestors' ids, closest first. */
3
+ export declare function getDomainIdsWithAncestors(domain: Domain): Promise<string[]>;
4
+ /**
5
+ * Keeps one item per key - the one declared by the closest domain - in the order the keys first
6
+ * appear. `domainIds` is the closest-first list from getDomainIdsWithAncestors.
7
+ */
8
+ export declare function dedupByClosestDomain<T extends {
9
+ domainId?: string;
10
+ }>(items: T[], domainIds: string[], keyFn: (item: T) => string | undefined): T[];
11
+ /** The active EnvVar of that name declared by the closest domain, or undefined. */
12
+ export declare function lookupDomainEnvVar(domain: Domain, name: string): Promise<string | undefined>;
13
+ /** Like lookupDomainEnvVar, and also says which domain the value came from. */
14
+ export declare function resolveDomainEnvVar(domain: Domain, name: string): Promise<{
15
+ value: string;
16
+ domainId: string;
17
+ updatedAt?: Date;
18
+ } | undefined>;
19
+ /**
20
+ * Every active EnvVar visible from the domain, merged closest-wins into one record. With a prefix,
21
+ * only the names that start with it.
22
+ */
23
+ export declare function loadDomainAttributes(domain: Domain, prefix?: string): Promise<Record<string, string>>;
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getDomainIdsWithAncestors = getDomainIdsWithAncestors;
4
+ exports.dedupByClosestDomain = dedupByClosestDomain;
5
+ exports.lookupDomainEnvVar = lookupDomainEnvVar;
6
+ exports.resolveDomainEnvVar = resolveDomainEnvVar;
7
+ exports.loadDomainAttributes = loadDomainAttributes;
8
+ const typeorm_1 = require("typeorm");
9
+ const database_js_1 = require("../initializers/database.js");
10
+ const domain_js_1 = require("../service/domain/domain.js");
11
+ const env_var_js_1 = require("../service/env-var/env-var.js");
12
+ /**
13
+ * Multi-level domain inheritance — the one place that walks a domain's ancestors.
14
+ *
15
+ * A child domain inherits what its ancestors declare, and the closest declaration wins.
16
+ * Callers use the ids for `domain IN (:...ids)` list queries, for picking the closest row of a
17
+ * single fetch, or for walking a fallback chain in order.
18
+ *
19
+ * Guarded against cycles and capped at a depth of 10.
20
+ */
21
+ const MAX_ANCESTOR_DEPTH = 10;
22
+ /** The domain's id followed by its ancestors' ids, closest first. */
23
+ async function getDomainIdsWithAncestors(domain) {
24
+ const ids = [domain.id];
25
+ const seen = new Set([domain.id]);
26
+ let parentId = domain.parentId;
27
+ let depth = 0;
28
+ while (parentId && !seen.has(parentId) && depth < MAX_ANCESTOR_DEPTH) {
29
+ seen.add(parentId);
30
+ ids.push(parentId);
31
+ const parent = await (0, database_js_1.getRepository)(domain_js_1.Domain).findOne({ where: { id: parentId } });
32
+ parentId = parent?.parentId;
33
+ depth++;
34
+ }
35
+ return ids;
36
+ }
37
+ /**
38
+ * Keeps one item per key - the one declared by the closest domain - in the order the keys first
39
+ * appear. `domainIds` is the closest-first list from getDomainIdsWithAncestors.
40
+ */
41
+ function dedupByClosestDomain(items, domainIds, keyFn) {
42
+ const rankOf = (domainId) => {
43
+ if (!domainId)
44
+ return Number.MAX_SAFE_INTEGER;
45
+ const idx = domainIds.indexOf(domainId);
46
+ return idx < 0 ? Number.MAX_SAFE_INTEGER : idx;
47
+ };
48
+ const byKey = new Map();
49
+ const order = [];
50
+ for (const item of items) {
51
+ const key = keyFn(item);
52
+ if (key == null)
53
+ continue;
54
+ const existing = byKey.get(key);
55
+ if (!existing) {
56
+ byKey.set(key, item);
57
+ order.push(key);
58
+ }
59
+ else if (rankOf(item.domainId) < rankOf(existing.domainId)) {
60
+ byKey.set(key, item);
61
+ }
62
+ }
63
+ return order.map(k => byKey.get(k));
64
+ }
65
+ /** The active EnvVar of that name declared by the closest domain, or undefined. */
66
+ async function lookupDomainEnvVar(domain, name) {
67
+ return (await resolveDomainEnvVar(domain, name))?.value;
68
+ }
69
+ /** Like lookupDomainEnvVar, and also says which domain the value came from. */
70
+ async function resolveDomainEnvVar(domain, name) {
71
+ if (!domain)
72
+ return undefined;
73
+ const ids = await getDomainIdsWithAncestors(domain);
74
+ const candidates = await (0, database_js_1.getRepository)(env_var_js_1.EnvVar).find({
75
+ where: { domain: { id: (0, typeorm_1.In)(ids) }, name, active: true }
76
+ });
77
+ const [closest] = dedupByClosestDomain(candidates, ids, () => name);
78
+ return closest ? { value: closest.value, domainId: closest.domainId, updatedAt: closest.updatedAt } : undefined;
79
+ }
80
+ /**
81
+ * Every active EnvVar visible from the domain, merged closest-wins into one record. With a prefix,
82
+ * only the names that start with it.
83
+ */
84
+ async function loadDomainAttributes(domain, prefix) {
85
+ if (!domain)
86
+ return {};
87
+ const ids = await getDomainIdsWithAncestors(domain);
88
+ const all = await (0, database_js_1.getRepository)(env_var_js_1.EnvVar).find({
89
+ where: { domain: { id: (0, typeorm_1.In)(ids) }, active: true }
90
+ });
91
+ const visible = all.filter(e => !prefix || e.name.startsWith(prefix));
92
+ const result = {};
93
+ for (const e of dedupByClosestDomain(visible, ids, e => e.name))
94
+ result[e.name] = e.value;
95
+ return result;
96
+ }
97
+ //# sourceMappingURL=domain-ancestry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"domain-ancestry.js","sourceRoot":"","sources":["../../server/utils/domain-ancestry.ts"],"names":[],"mappings":"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","sourcesContent":["import { In } from 'typeorm'\n\nimport { getRepository } from '../initializers/database.js'\nimport { Domain } from '../service/domain/domain.js'\nimport { EnvVar } from '../service/env-var/env-var.js'\n\n/**\n * Multi-level domain inheritance — the one place that walks a domain's ancestors.\n *\n * A child domain inherits what its ancestors declare, and the closest declaration wins.\n * Callers use the ids for `domain IN (:...ids)` list queries, for picking the closest row of a\n * single fetch, or for walking a fallback chain in order.\n *\n * Guarded against cycles and capped at a depth of 10.\n */\nconst MAX_ANCESTOR_DEPTH = 10\n\n/** The domain's id followed by its ancestors' ids, closest first. */\nexport async function getDomainIdsWithAncestors(domain: Domain): Promise<string[]> {\n const ids: string[] = [domain.id]\n const seen = new Set<string>([domain.id])\n\n let parentId: string | undefined = domain.parentId\n let depth = 0\n\n while (parentId && !seen.has(parentId) && depth < MAX_ANCESTOR_DEPTH) {\n seen.add(parentId)\n ids.push(parentId)\n\n const parent = await getRepository(Domain).findOne({ where: { id: parentId } })\n parentId = parent?.parentId\n depth++\n }\n\n return ids\n}\n\n/**\n * Keeps one item per key - the one declared by the closest domain - in the order the keys first\n * appear. `domainIds` is the closest-first list from getDomainIdsWithAncestors.\n */\nexport function dedupByClosestDomain<T extends { domainId?: string }>(\n items: T[],\n domainIds: string[],\n keyFn: (item: T) => string | undefined\n): T[] {\n const rankOf = (domainId: string | undefined): number => {\n if (!domainId) return Number.MAX_SAFE_INTEGER\n const idx = domainIds.indexOf(domainId)\n return idx < 0 ? Number.MAX_SAFE_INTEGER : idx\n }\n\n const byKey = new Map<string, T>()\n const order: string[] = []\n\n for (const item of items) {\n const key = keyFn(item)\n if (key == null) continue\n\n const existing = byKey.get(key)\n if (!existing) {\n byKey.set(key, item)\n order.push(key)\n } else if (rankOf(item.domainId) < rankOf(existing.domainId)) {\n byKey.set(key, item)\n }\n }\n\n return order.map(k => byKey.get(k)!)\n}\n\n/** The active EnvVar of that name declared by the closest domain, or undefined. */\nexport async function lookupDomainEnvVar(domain: Domain, name: string): Promise<string | undefined> {\n return (await resolveDomainEnvVar(domain, name))?.value\n}\n\n/** Like lookupDomainEnvVar, and also says which domain the value came from. */\nexport async function resolveDomainEnvVar(\n domain: Domain,\n name: string\n): Promise<{ value: string; domainId: string; updatedAt?: Date } | undefined> {\n if (!domain) return undefined\n const ids = await getDomainIdsWithAncestors(domain)\n const candidates = await getRepository(EnvVar).find({\n where: { domain: { id: In(ids) }, name, active: true }\n })\n const [closest] = dedupByClosestDomain(candidates, ids, () => name)\n return closest ? { value: closest.value, domainId: closest.domainId, updatedAt: closest.updatedAt } : undefined\n}\n\n/**\n * Every active EnvVar visible from the domain, merged closest-wins into one record. With a prefix,\n * only the names that start with it.\n */\nexport async function loadDomainAttributes(domain: Domain, prefix?: string): Promise<Record<string, string>> {\n if (!domain) return {}\n const ids = await getDomainIdsWithAncestors(domain)\n const all = await getRepository(EnvVar).find({\n where: { domain: { id: In(ids) }, active: true }\n })\n const visible = all.filter(e => !prefix || e.name.startsWith(prefix))\n const result: Record<string, string> = {}\n for (const e of dedupByClosestDomain(visible, ids, e => e.name)) result[e.name] = e.value\n return result\n}\n"]}
@@ -11,3 +11,5 @@ export * from './get-times-for-period.js';
11
11
  export * from './headless-pool/index.js';
12
12
  export * from './subscribe-auth.js';
13
13
  export * from './after-commit.js';
14
+ export * from './domain-ancestry.js';
15
+ export * from './secret-hint.js';
@@ -14,4 +14,6 @@ tslib_1.__exportStar(require("./get-times-for-period.js"), exports);
14
14
  tslib_1.__exportStar(require("./headless-pool/index.js"), exports);
15
15
  tslib_1.__exportStar(require("./subscribe-auth.js"), exports);
16
16
  tslib_1.__exportStar(require("./after-commit.js"), exports);
17
+ tslib_1.__exportStar(require("./domain-ancestry.js"), exports);
18
+ tslib_1.__exportStar(require("./secret-hint.js"), exports);
17
19
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/utils/index.ts"],"names":[],"mappings":";;;AAAA,0DAA+B;AAC/B,mEAAwC;AACxC,iEAAsC;AACtC,kEAAuC;AACvC,qEAA0C;AAC1C,gEAAqC;AACrC,kFAAuD;AACvD,6DAAkC;AAClC,mEAAwC;AACxC,oEAAyC;AACzC,mEAAwC;AACxC,8DAAmC;AACnC,4DAAiC","sourcesContent":["export * from './get-domain.js'\nexport * from './apply-domain-filter.js'\nexport * from './condition-builder.js'\nexport * from './list-query-builder.js'\nexport * from './list-params-converter.js'\nexport * from './publish-progress.js'\nexport * from './get-query-builder-from-list-params.js'\nexport * from './keyset-cursor.js'\nexport * from './list-param-adjuster.js'\nexport * from './get-times-for-period.js'\nexport * from './headless-pool/index.js'\nexport * from './subscribe-auth.js'\nexport * from './after-commit.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../server/utils/index.ts"],"names":[],"mappings":";;;AAAA,0DAA+B;AAC/B,mEAAwC;AACxC,iEAAsC;AACtC,kEAAuC;AACvC,qEAA0C;AAC1C,gEAAqC;AACrC,kFAAuD;AACvD,6DAAkC;AAClC,mEAAwC;AACxC,oEAAyC;AACzC,mEAAwC;AACxC,8DAAmC;AACnC,4DAAiC;AACjC,+DAAoC;AACpC,2DAAgC","sourcesContent":["export * from './get-domain.js'\nexport * from './apply-domain-filter.js'\nexport * from './condition-builder.js'\nexport * from './list-query-builder.js'\nexport * from './list-params-converter.js'\nexport * from './publish-progress.js'\nexport * from './get-query-builder-from-list-params.js'\nexport * from './keyset-cursor.js'\nexport * from './list-param-adjuster.js'\nexport * from './get-times-for-period.js'\nexport * from './headless-pool/index.js'\nexport * from './subscribe-auth.js'\nexport * from './after-commit.js'\nexport * from './domain-ancestry.js'\nexport * from './secret-hint.js'\n"]}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * What of a withheld value may be shown, so that a person can tell which one is set (2026-10-04,
3
+ * user decision on ADR-0112):
4
+ *
5
+ * 16 characters or more the last four ••••xyze
6
+ * 4 to 15 the first and the last a••••e
7
+ * fewer than 4 nothing ••••
8
+ *
9
+ * The middle is never shown, and the mask is always four dots so the length does not show either.
10
+ * Made here from the stored value - the value itself never leaves the server.
11
+ */
12
+ export declare function secretHint(value: string | null | undefined): string | undefined;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.secretHint = secretHint;
4
+ /**
5
+ * What of a withheld value may be shown, so that a person can tell which one is set (2026-10-04,
6
+ * user decision on ADR-0112):
7
+ *
8
+ * 16 characters or more the last four ••••xyze
9
+ * 4 to 15 the first and the last a••••e
10
+ * fewer than 4 nothing ••••
11
+ *
12
+ * The middle is never shown, and the mask is always four dots so the length does not show either.
13
+ * Made here from the stored value - the value itself never leaves the server.
14
+ */
15
+ function secretHint(value) {
16
+ if (value === null || value === undefined || value === '') {
17
+ return undefined;
18
+ }
19
+ const characters = Array.from(String(value));
20
+ const MASK = '••••';
21
+ if (characters.length >= 16) {
22
+ return MASK + characters.slice(-4).join('');
23
+ }
24
+ if (characters.length >= 4) {
25
+ return characters[0] + MASK + characters[characters.length - 1];
26
+ }
27
+ return MASK;
28
+ }
29
+ //# sourceMappingURL=secret-hint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-hint.js","sourceRoot":"","sources":["../../server/utils/secret-hint.ts"],"names":[],"mappings":";;AAWA,gCAeC;AA1BD;;;;;;;;;;GAUG;AACH,SAAgB,UAAU,CAAC,KAAgC;IACzD,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;QAC1D,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAA;IAC5C,MAAM,IAAI,GAAG,MAAM,CAAA;IAEnB,IAAI,UAAU,CAAC,MAAM,IAAI,EAAE,EAAE,CAAC;QAC5B,OAAO,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAC7C,CAAC;IACD,IAAI,UAAU,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;QAC3B,OAAO,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,GAAG,UAAU,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IACjE,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["/**\n * What of a withheld value may be shown, so that a person can tell which one is set (2026-10-04,\n * user decision on ADR-0112):\n *\n * 16 characters or more the last four ••••xyze\n * 4 to 15 the first and the last a••••e\n * fewer than 4 nothing ••••\n *\n * The middle is never shown, and the mask is always four dots so the length does not show either.\n * Made here from the stored value - the value itself never leaves the server.\n */\nexport function secretHint(value: string | null | undefined): string | undefined {\n if (value === null || value === undefined || value === '') {\n return undefined\n }\n\n const characters = Array.from(String(value))\n const MASK = '••••'\n\n if (characters.length >= 16) {\n return MASK + characters.slice(-4).join('')\n }\n if (characters.length >= 4) {\n return characters[0] + MASK + characters[characters.length - 1]\n }\n return MASK\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/shell",
3
- "version": "10.1.150",
3
+ "version": "10.1.155",
4
4
  "description": "Core module for framework",
5
5
  "bin": {
6
6
  "things-factory": "bin/things-factory",
@@ -142,5 +142,5 @@
142
142
  "pg": "^8.7.3",
143
143
  "sqlite3": "^5.0.8"
144
144
  },
145
- "gitHead": "39c135863f5f7455dcd80b8907550a1c91a86118"
145
+ "gitHead": "0211c9c0764a26c10151fdc7d60094fc334905ff"
146
146
  }
@@ -0,0 +1,47 @@
1
+ import { ApolloServer } from '@apollo/server'
2
+ import { buildSchema } from 'graphql'
3
+ import { QueryFailedError } from 'typeorm'
4
+
5
+ import { databaseErrorsUnspoken, DATABASE_FAILED } from '../server/database-errors-unspoken'
6
+
7
+ /**
8
+ * 2026-10-12: operato-office's save toast read `저장하지 못했습니다: null value in column "original_id" of relation
9
+ * "transaction_histories" violates not-null constraint`. A database error answers one sentence in the request's
10
+ * language; any other error keeps its own message.
11
+ */
12
+ const NOT_NULL = 'null value in column "original_id" of relation "transaction_histories" violates not-null constraint'
13
+
14
+ const schema = buildSchema(`type Query { database: String, refused: String }`)
15
+ const rootValue = {
16
+ database: () => {
17
+ throw new QueryFailedError('INSERT INTO "transaction_histories" ...', [], new Error(NOT_NULL))
18
+ },
19
+ refused: () => {
20
+ throw new Error('a counterparty needs a name')
21
+ }
22
+ }
23
+
24
+ const server = new ApolloServer({ schema, rootValue, plugins: [databaseErrorsUnspoken()] })
25
+
26
+ beforeAll(() => server.start())
27
+ afterAll(() => server.stop())
28
+
29
+ const t = (key: string) => (key === DATABASE_FAILED ? '서버에서 처리하지 못했습니다. 관리자에게 알려 주세요.' : key)
30
+
31
+ const ask = async (query: string) => {
32
+ const response = await server.executeOperation({ query }, { contextValue: { t } })
33
+ return response.body.kind === 'single' ? response.body.singleResult : undefined
34
+ }
35
+
36
+ describe('an error from the database', () => {
37
+ it('answers one sentence in the request language, not the database text', async () => {
38
+ const message = (await ask('{ database }'))?.errors?.[0]?.message
39
+
40
+ expect(message).toBe('서버에서 처리하지 못했습니다. 관리자에게 알려 주세요.')
41
+ expect(message).not.toContain('original_id')
42
+ })
43
+
44
+ it('leaves any other error as it is', async () => {
45
+ expect((await ask('{ refused }'))?.errors?.[0]?.message).toBe('a counterparty needs a name')
46
+ })
47
+ })
@@ -171,6 +171,7 @@
171
171
  "label.released": "released",
172
172
  "label.import": "import",
173
173
  "error.address-not-allowed": "this site does not accept connections from where you are. ask its administrator.",
174
+ "error.database-failed": "The server could not process this. Tell your administrator.",
174
175
  "button.undo": "undo",
175
176
  "button.redo": "redo",
176
177
  "button.fit-view": "fit to view",
@@ -182,5 +183,7 @@
182
183
  "text.unsaved-draft-kept": "what you changed {minutes} minutes ago is still kept in this browser.",
183
184
  "text.unsaved-draft-behind": "it has been saved again since then. saving will tell you about the conflict.",
184
185
  "button.resume-editing": "resume editing",
185
- "button.discard": "discard"
186
+ "button.discard": "discard",
187
+ "figure.label.motion": "Motion",
188
+ "figure.text.preview-needs-a-part": "add a part to see how it looks placed on a drawing."
186
189
  }
@@ -171,6 +171,7 @@
171
171
  "label.released": "publicado",
172
172
  "label.import": "importar",
173
173
  "error.address-not-allowed": "este sitio no acepta conexiones desde su ubicación actual. consulte a su administrador.",
174
+ "error.database-failed": "El servidor no pudo procesar esto. Avise a su administrador.",
174
175
  "button.undo": "deshacer",
175
176
  "button.redo": "rehacer",
176
177
  "button.fit-view": "ajustar a la vista",
@@ -182,5 +183,7 @@
182
183
  "text.unsaved-draft-kept": "los cambios que hizo hace {minutes} minutos siguen guardados en este navegador.",
183
184
  "text.unsaved-draft-behind": "desde entonces se ha vuelto a guardar. al guardar se le avisará del conflicto.",
184
185
  "button.resume-editing": "reanudar edición",
185
- "button.discard": "descartar"
186
+ "button.discard": "descartar",
187
+ "figure.label.motion": "Movimiento",
188
+ "figure.text.preview-needs-a-part": "añada una pieza para ver cómo se ve colocada en un plano."
186
189
  }
@@ -171,6 +171,7 @@
171
171
  "label.released": "公開済み",
172
172
  "label.import": "インポート",
173
173
  "error.address-not-allowed": "この事業所は現在の場所からの接続を受け付けていません。事業所の管理者にお問い合わせください。",
174
+ "error.database-failed": "サーバーで処理できませんでした。管理者にお知らせください。",
174
175
  "button.undo": "元に戻す",
175
176
  "button.redo": "やり直す",
176
177
  "button.fit-view": "画面に合わせる",
@@ -182,5 +183,7 @@
182
183
  "text.unsaved-draft-kept": "{minutes}分前に変更した内容がこのブラウザに残っています。",
183
184
  "text.unsaved-draft-behind": "その後、再び保存されています。保存するときに競合をお知らせします。",
184
185
  "button.resume-editing": "編集を続ける",
185
- "button.discard": "破棄"
186
+ "button.discard": "破棄",
187
+ "figure.label.motion": "Motion",
188
+ "figure.text.preview-needs-a-part": "部品を追加すると、図面に置いたときの姿が表示されます。"
186
189
  }
@@ -171,6 +171,7 @@
171
171
  "label.released": "발행됨",
172
172
  "label.import": "가져오기",
173
173
  "error.address-not-allowed": "이 사업장은 지금 계신 곳에서의 접속을 받지 않습니다. 사업장 관리자에게 문의하십시오.",
174
+ "error.database-failed": "서버에서 처리하지 못했습니다. 관리자에게 알려 주세요.",
174
175
  "button.undo": "되돌리기",
175
176
  "button.redo": "다시 실행",
176
177
  "button.fit-view": "화면에 맞추기",
@@ -182,5 +183,7 @@
182
183
  "text.unsaved-draft-kept": "{minutes}분 전에 고친 내용이 이 브라우저에 남아 있습니다.",
183
184
  "text.unsaved-draft-behind": "그 뒤에 다시 저장되었습니다. 저장할 때 충돌을 알려 드립니다.",
184
185
  "button.resume-editing": "이어서 편집",
185
- "button.discard": "버리기"
186
+ "button.discard": "버리기",
187
+ "figure.label.motion": "움직임",
188
+ "figure.text.preview-needs-a-part": "부품을 추가하면 도면에 놓았을 때의 모습이 표시됩니다."
186
189
  }
@@ -171,6 +171,7 @@
171
171
  "label.released": "diterbitkan",
172
172
  "label.import": "import",
173
173
  "error.address-not-allowed": "tapak ini tidak menerima sambungan dari lokasi anda. sila hubungi pentadbirnya.",
174
+ "error.database-failed": "Pelayan tidak dapat memproses ini. Sila maklumkan kepada pentadbir anda.",
174
175
  "button.undo": "buat asal",
175
176
  "button.redo": "buat semula",
176
177
  "button.fit-view": "muat ke paparan",
@@ -182,5 +183,7 @@
182
183
  "text.unsaved-draft-kept": "perubahan yang anda buat {minutes} minit lalu masih disimpan dalam pelayar ini.",
183
184
  "text.unsaved-draft-behind": "ia telah disimpan semula sejak itu. semasa menyimpan, anda akan dimaklumkan tentang konflik.",
184
185
  "button.resume-editing": "sambung menyunting",
185
- "button.discard": "buang"
186
+ "button.discard": "buang",
187
+ "figure.label.motion": "Motion",
188
+ "figure.text.preview-needs-a-part": "tambah bahagian untuk melihat rupanya apabila diletakkan pada lukisan."
186
189
  }
@@ -171,6 +171,7 @@
171
171
  "label.released": "已发布",
172
172
  "label.import": "导入",
173
173
  "error.address-not-allowed": "此站点不接受来自您当前位置的连接。请联系站点管理员。",
174
+ "error.database-failed": "服务器无法处理此请求。请告知管理员。",
174
175
  "button.undo": "撤销",
175
176
  "button.redo": "重做",
176
177
  "button.fit-view": "适应视图",
@@ -182,5 +183,7 @@
182
183
  "text.unsaved-draft-kept": "您 {minutes} 分钟前所做的更改仍保留在此浏览器中。",
183
184
  "text.unsaved-draft-behind": "此后已被再次保存。保存时会提示冲突。",
184
185
  "button.resume-editing": "继续编辑",
185
- "button.discard": "放弃"
186
+ "button.discard": "放弃",
187
+ "figure.label.motion": "Motion",
188
+ "figure.text.preview-needs-a-part": "添加部件后会显示放置到图面上的效果。"
186
189
  }