@open-mercato/core 0.10.1-develop.7972.1.03f813eaaf → 0.10.1-develop.7974.1.c98f9304d9

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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/communicationChannelsFixtures.js +11 -0
  3. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/set-primary/route.js +16 -1
  5. package/dist/modules/communication_channels/api/post/channels/[id]/set-primary/route.js.map +2 -2
  6. package/dist/modules/communication_channels/api/post/test-seed/route.js +31 -0
  7. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +2 -2
  8. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +1 -1
  9. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  10. package/dist/modules/communication_channels/commands/set-primary-channel.js +3 -0
  11. package/dist/modules/communication_channels/commands/set-primary-channel.js.map +2 -2
  12. package/dist/modules/dictionaries/api/[dictionaryId]/route.js +9 -2
  13. package/dist/modules/dictionaries/api/[dictionaryId]/route.js.map +2 -2
  14. package/dist/modules/dictionaries/api/route.js +9 -2
  15. package/dist/modules/dictionaries/api/route.js.map +2 -2
  16. package/dist/modules/dictionaries/components/AppearanceSelector.js +4 -1
  17. package/dist/modules/dictionaries/components/AppearanceSelector.js.map +2 -2
  18. package/dist/modules/dictionaries/components/DictionariesManager.js +2 -2
  19. package/dist/modules/dictionaries/components/DictionariesManager.js.map +2 -2
  20. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js +1 -1
  21. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +1 -1
  22. package/package.json +7 -7
  23. package/src/helpers/integration/communicationChannelsFixtures.ts +19 -0
  24. package/src/modules/communication_channels/api/post/channels/[id]/set-primary/route.ts +15 -0
  25. package/src/modules/communication_channels/api/post/test-seed/route.ts +38 -0
  26. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +1 -1
  27. package/src/modules/communication_channels/commands/set-primary-channel.ts +7 -0
  28. package/src/modules/communication_channels/i18n/de.json +1 -0
  29. package/src/modules/communication_channels/i18n/en.json +1 -0
  30. package/src/modules/communication_channels/i18n/es.json +1 -0
  31. package/src/modules/communication_channels/i18n/ko.json +1 -0
  32. package/src/modules/communication_channels/i18n/pl.json +1 -0
  33. package/src/modules/dictionaries/api/[dictionaryId]/route.ts +9 -2
  34. package/src/modules/dictionaries/api/route.ts +9 -2
  35. package/src/modules/dictionaries/components/AppearanceSelector.tsx +4 -1
  36. package/src/modules/dictionaries/components/DictionariesManager.tsx +2 -2
  37. package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +1 -1
  38. package/src/modules/dictionaries/i18n/de.json +1 -0
  39. package/src/modules/dictionaries/i18n/en.json +1 -0
  40. package/src/modules/dictionaries/i18n/es.json +1 -0
  41. package/src/modules/dictionaries/i18n/ko.json +1 -0
  42. package/src/modules/dictionaries/i18n/pl.json +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7972.1.03f813eaaf",
3
+ "version": "0.10.1-develop.7974.1.c98f9304d9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.6.6"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7972.1.03f813eaaf",
270
- "@open-mercato/shared": "0.10.1-develop.7972.1.03f813eaaf",
271
- "@open-mercato/ui": "0.10.1-develop.7972.1.03f813eaaf",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7974.1.c98f9304d9",
270
+ "@open-mercato/shared": "0.10.1-develop.7974.1.c98f9304d9",
271
+ "@open-mercato/ui": "0.10.1-develop.7974.1.c98f9304d9",
272
272
  "react": "^19.0.0",
273
273
  "react-dom": "^19.0.0"
274
274
  },
275
275
  "devDependencies": {
276
- "@open-mercato/ai-assistant": "0.10.1-develop.7972.1.03f813eaaf",
277
- "@open-mercato/shared": "0.10.1-develop.7972.1.03f813eaaf",
278
- "@open-mercato/ui": "0.10.1-develop.7972.1.03f813eaaf",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7974.1.c98f9304d9",
277
+ "@open-mercato/shared": "0.10.1-develop.7974.1.c98f9304d9",
278
+ "@open-mercato/ui": "0.10.1-develop.7974.1.c98f9304d9",
279
279
  "@testing-library/dom": "^10.4.1",
280
280
  "@testing-library/jest-dom": "^7.0.1",
281
281
  "@testing-library/react": "^16.3.3",
@@ -111,6 +111,25 @@ export async function seedConnectedChannel(
111
111
  return expectId(body?.channelId, 'connect-channel response should include channelId');
112
112
  }
113
113
 
114
+ /**
115
+ * Disconnect a channel the caller owns through the real disconnect command. The
116
+ * row stays listed with `status: 'disconnected'` (a UI disconnect deletes it).
117
+ */
118
+ export async function disconnectSeededChannel(
119
+ request: APIRequestContext,
120
+ token: string,
121
+ channelId: string,
122
+ ): Promise<void> {
123
+ const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {
124
+ token,
125
+ data: { action: 'disconnect-channel', channelId },
126
+ });
127
+ expect(
128
+ response.status(),
129
+ 'POST /api/communication_channels/test-seed (disconnect-channel) should return 200',
130
+ ).toBe(200);
131
+ }
132
+
114
133
  export async function seedSystemEmailChannel(
115
134
  request: APIRequestContext,
116
135
  token: string,
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
4
5
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
6
  import type { CommandBus } from '@open-mercato/shared/lib/commands'
6
7
  import {
@@ -82,6 +83,19 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
82
83
  if (result.status === 'not_owner') {
83
84
  return NextResponse.json({ error: 'Channel not found' }, { status: 404 })
84
85
  }
86
+ if (result.status === 'disconnected') {
87
+ const { translate } = await resolveTranslations()
88
+ return NextResponse.json(
89
+ {
90
+ error: translate(
91
+ 'communication_channels.errors.setPrimaryDisconnected',
92
+ 'This channel is disconnected. Reconnect it before setting it as primary.',
93
+ ),
94
+ code: 'channel_disconnected',
95
+ },
96
+ { status: 409 },
97
+ )
98
+ }
85
99
  if (result.status === 'noop') {
86
100
  return NextResponse.json({ channelId: id, isPrimary: true, unchanged: true }, { status: 200 })
87
101
  }
@@ -107,6 +121,7 @@ export const openApi = {
107
121
  { status: 400, description: 'Invalid channel id' },
108
122
  { status: 401, description: 'Unauthorized' },
109
123
  { status: 404, description: 'Channel not found or not owned by current user' },
124
+ { status: 409, description: 'Channel is disconnected and cannot be set as primary' },
110
125
  ],
111
126
  },
112
127
  },
@@ -22,6 +22,11 @@ import {
22
22
  type ConnectCredentialChannelInput,
23
23
  type ConnectCredentialChannelResult,
24
24
  } from '../../../commands/connect-credential-channel'
25
+ import {
26
+ COMMUNICATION_CHANNELS_DISCONNECT_CHANNEL_COMMAND_ID,
27
+ type DisconnectChannelInput,
28
+ type DisconnectChannelResult,
29
+ } from '../../../commands/disconnect-channel'
25
30
  import { emitCommunicationChannelsEvent } from '../../../events'
26
31
  import {
27
32
  TEST_SEED_CHAT_PROVIDER_KEY,
@@ -67,6 +72,9 @@ import {
67
72
  * the hub accepts a message, only that downstream subscribers fire.
68
73
  * - `purge-inbound`: hard-delete the rows one test-seeded or test-ingested
69
74
  * inbound message created, for integration teardown.
75
+ * - `disconnect-channel`: run the REAL disconnect command on a channel the
76
+ * caller owns. No HTTP route disconnects without deleting the row, so this is
77
+ * the only way a test can reach a listed `disconnected` channel.
70
78
  */
71
79
  type RbacServiceLike = {
72
80
  loadAcl: (
@@ -158,6 +166,11 @@ const seedSystemChannelSchema = z.object({
158
166
  externalIdentifier: z.string().min(1).max(255).optional(),
159
167
  })
160
168
 
169
+ const disconnectChannelSchema = z.object({
170
+ action: z.literal('disconnect-channel'),
171
+ channelId: z.string().uuid(),
172
+ })
173
+
161
174
  const clearCaptureSchema = z.object({
162
175
  action: z.literal('clear-capture'),
163
176
  systemRecipient: z.string().email().max(320).optional(),
@@ -237,6 +250,7 @@ const bodySchema = z.discriminatedUnion('action', [
237
250
  emitInboundSchema,
238
251
  purgeInboundSchema,
239
252
  seedSystemChannelSchema,
253
+ disconnectChannelSchema,
240
254
  clearCaptureSchema,
241
255
  listCaptureSchema,
242
256
  ])
@@ -408,6 +422,30 @@ export async function POST(req: Request): Promise<Response> {
408
422
  return NextResponse.json({ channelId: channel.id }, { status: 201 })
409
423
  }
410
424
 
425
+ if (body.action === 'disconnect-channel') {
426
+ const commandBus = container.resolve('commandBus') as CommandBus
427
+ const { result } = await commandBus.execute<DisconnectChannelInput, DisconnectChannelResult>(
428
+ COMMUNICATION_CHANNELS_DISCONNECT_CHANNEL_COMMAND_ID,
429
+ {
430
+ input: { channelId: body.channelId, userId, scope: { tenantId, organizationId } },
431
+ ctx: {
432
+ container,
433
+ auth: auth as never,
434
+ organizationScope: null,
435
+ selectedOrganizationId: organizationId,
436
+ organizationIds: organizationId ? [organizationId] : null,
437
+ },
438
+ },
439
+ )
440
+ if (result.status !== 'disconnected') {
441
+ return NextResponse.json(
442
+ { error: '[internal] test-seed disconnect failed', detail: { status: result.status } },
443
+ { status: 500 },
444
+ )
445
+ }
446
+ return NextResponse.json({ channelId: result.channelId, status: result.status }, { status: 200 })
447
+ }
448
+
411
449
  if (body.action === 'connect-channel') {
412
450
  const stamp = Date.now()
413
451
  const commandBus = container.resolve('commandBus') as CommandBus
@@ -537,7 +537,7 @@ export default function ProfileCommunicationChannelsPage() {
537
537
  (channel: ChannelRow) => {
538
538
  const items: RowActionItem[] = []
539
539
 
540
- if (!channel.isPrimary) {
540
+ if (!channel.isPrimary && channel.status !== 'disconnected') {
541
541
  items.push({
542
542
  id: 'set-primary',
543
543
  label: t('communication_channels.profile.actions.setPrimary', 'Set as primary'),
@@ -22,6 +22,7 @@ export type SetPrimaryChannelResult =
22
22
  | { status: 'set'; channelId: string; previousPrimaryChannelId: string | null }
23
23
  | { status: 'noop'; reason: string }
24
24
  | { status: 'not_owner'; reason: string }
25
+ | { status: 'disconnected'; reason: string }
25
26
 
26
27
  export const COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID =
27
28
  'communication_channels.channel.set_primary'
@@ -32,6 +33,9 @@ export const COMMUNICATION_CHANNELS_SET_PRIMARY_COMMAND_ID =
32
33
  * partial unique index `communication_channels_one_primary_per_user_uq`.
33
34
  *
34
35
  * Ownership-checked: refuses to set a primary on someone else's channel.
36
+ * Refuses a disconnected channel: every path that disconnects a channel also
37
+ * clears its primary flag, so promoting one would hand the user a primary that
38
+ * cannot send.
35
39
  */
36
40
  const setPrimaryChannelCommand: CommandHandler<
37
41
  SetPrimaryChannelInput,
@@ -75,6 +79,9 @@ const setPrimaryChannelCommand: CommandHandler<
75
79
  if (channel.userId !== input.userId) {
76
80
  return { status: 'not_owner', reason: 'channel is not owned by the current user' }
77
81
  }
82
+ if (channel.status === 'disconnected') {
83
+ return { status: 'disconnected', reason: 'channel is disconnected' }
84
+ }
78
85
  if (channel.isPrimary) {
79
86
  return { status: 'noop', reason: 'channel is already primary' }
80
87
  }
@@ -30,6 +30,7 @@
30
30
  "communication_channels.errors.pollNowPushDriven": "Der Kanal ist Push-gesteuert — Abrufen ist nicht anwendbar. Eingehende Nachrichten kommen über die Push-Verbindung des Anbieters.",
31
31
  "communication_channels.errors.reactionFailed": "Reaktion fehlgeschlagen",
32
32
  "communication_channels.errors.reactionNotFound": "Reaktion nicht gefunden",
33
+ "communication_channels.errors.setPrimaryDisconnected": "Dieser Kanal ist getrennt. Verbinde ihn erneut, bevor du ihn als primär festlegst.",
33
34
  "communication_channels.errors.threadNotFound": "Thread nicht gefunden",
34
35
  "communication_channels.errors.undoBlockedPrimaryConflict": "Ein anderer Kanal ist jetzt für diesen Benutzer als primär festgelegt. Setze ihn auf nicht-primär, bevor du den getrennten Kanal wieder als primär festlegst.",
35
36
  "communication_channels.infoPanel.aria": "Kanalinfo",
@@ -30,6 +30,7 @@
30
30
  "communication_channels.errors.pollNowPushDriven": "Channel is push-driven — polling does not apply. Inbound messages arrive through the provider push connection.",
31
31
  "communication_channels.errors.reactionFailed": "Reaction failed",
32
32
  "communication_channels.errors.reactionNotFound": "Reaction not found",
33
+ "communication_channels.errors.setPrimaryDisconnected": "This channel is disconnected. Reconnect it before setting it as primary.",
33
34
  "communication_channels.errors.threadNotFound": "Thread not found",
34
35
  "communication_channels.errors.undoBlockedPrimaryConflict": "Another channel is now primary for this user. Set it as non-primary before restoring the disconnected channel as primary.",
35
36
  "communication_channels.infoPanel.aria": "Channel info",
@@ -30,6 +30,7 @@
30
30
  "communication_channels.errors.pollNowPushDriven": "El canal funciona con push — el sondeo no aplica. Los mensajes entrantes llegan por la conexión push del proveedor.",
31
31
  "communication_channels.errors.reactionFailed": "La reacción falló",
32
32
  "communication_channels.errors.reactionNotFound": "Reacción no encontrada",
33
+ "communication_channels.errors.setPrimaryDisconnected": "Este canal está desconectado. Vuelve a conectarlo antes de establecerlo como principal.",
33
34
  "communication_channels.errors.threadNotFound": "Hilo no encontrado",
34
35
  "communication_channels.errors.undoBlockedPrimaryConflict": "Otro canal es ahora el principal para este usuario. Configúralo como no principal antes de restaurar el canal desconectado como principal.",
35
36
  "communication_channels.infoPanel.aria": "Información del canal",
@@ -30,6 +30,7 @@
30
30
  "communication_channels.errors.pollNowPushDriven": "이 채널은 푸시 기반입니다 — 폴링은 적용되지 않습니다. 수신 메시지는 공급자의 푸시 연결로 전달됩니다.",
31
31
  "communication_channels.errors.reactionFailed": "반응 처리에 실패했습니다",
32
32
  "communication_channels.errors.reactionNotFound": "반응을 찾을 수 없습니다",
33
+ "communication_channels.errors.setPrimaryDisconnected": "이 채널은 연결이 끊겼습니다. 기본으로 설정하기 전에 다시 연결하세요.",
33
34
  "communication_channels.errors.threadNotFound": "스레드를 찾을 수 없습니다",
34
35
  "communication_channels.errors.undoBlockedPrimaryConflict": "다른 채널이 현재 이 사용자의 기본 채널로 설정되어 있습니다. 연결이 끊어진 채널을 기본으로 복원하기 전에 해당 채널을 기본이 아닌 채널로 설정하세요.",
35
36
  "communication_channels.infoPanel.aria": "채널 정보",
@@ -30,6 +30,7 @@
30
30
  "communication_channels.errors.pollNowPushDriven": "Kanał działa w trybie push — odpytywanie go nie dotyczy. Wiadomości przychodzące docierają połączeniem push dostawcy.",
31
31
  "communication_channels.errors.reactionFailed": "Reakcja nie powiodła się",
32
32
  "communication_channels.errors.reactionNotFound": "Nie znaleziono reakcji",
33
+ "communication_channels.errors.setPrimaryDisconnected": "Ten kanał jest rozłączony. Połącz go ponownie, zanim ustawisz go jako główny.",
33
34
  "communication_channels.errors.threadNotFound": "Nie znaleziono wątku",
34
35
  "communication_channels.errors.undoBlockedPrimaryConflict": "Inny kanał jest teraz głównym dla tego użytkownika. Ustaw go jako niegłówny, zanim przywrócisz rozłączony kanał jako główny.",
35
36
  "communication_channels.infoPanel.aria": "Informacje o kanale",
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { Dictionary } from '@open-mercato/core/modules/dictionaries/data/entities'
4
4
  import { resolveDictionariesRouteContext, resolveDictionaryActorId } from '@open-mercato/core/modules/dictionaries/api/context'
5
- import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
+ import { CrudHttpError, isCrudHttpError, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
6
6
  import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
7
7
  import {
8
8
  runCrudMutationGuardAfterSuccess,
@@ -191,7 +191,14 @@ export async function PATCH(req: Request, ctx: { params?: { dictionaryId?: strin
191
191
  }
192
192
 
193
193
  dictionary.updatedAt = new Date()
194
- await context.em.flush()
194
+ try {
195
+ await context.em.flush()
196
+ } catch (err) {
197
+ if (isUniqueViolation(err, 'dictionaries_scope_key_unique')) {
198
+ throw new CrudHttpError(409, { error: context.translate('dictionaries.errors.duplicate', 'A dictionary with this key already exists') })
199
+ }
200
+ throw err
201
+ }
195
202
 
196
203
  if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
197
204
  await runCrudMutationGuardAfterSuccess(context.container, {
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { Dictionary } from '@open-mercato/core/modules/dictionaries/data/entities'
4
4
  import { resolveDictionariesRouteContext, resolveDictionaryActorId } from '@open-mercato/core/modules/dictionaries/api/context'
5
- import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
+ import { CrudHttpError, isCrudHttpError, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
6
6
  import {
7
7
  runCrudMutationGuardAfterSuccess,
8
8
  validateCrudMutationGuard,
@@ -126,7 +126,14 @@ export async function POST(req: Request) {
126
126
  }
127
127
 
128
128
  context.em.persist(dictionary)
129
- await context.em.flush()
129
+ try {
130
+ await context.em.flush()
131
+ } catch (err) {
132
+ if (isUniqueViolation(err, 'dictionaries_scope_key_unique')) {
133
+ throw new CrudHttpError(409, { error: context.translate('dictionaries.errors.duplicate', 'A dictionary with this key already exists') })
134
+ }
135
+ throw err
136
+ }
130
137
 
131
138
  if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
132
139
  await runCrudMutationGuardAfterSuccess(context.container, {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { Ellipsis } from 'lucide-react'
5
+ import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
5
6
  import { Button } from '@open-mercato/ui/primitives/button'
6
7
  import { ColorPicker } from '@open-mercato/ui/primitives/color-picker'
7
8
  import { Input } from '@open-mercato/ui/primitives/input'
@@ -52,6 +53,8 @@ export function AppearanceSelector({
52
53
  className,
53
54
  previewLabel,
54
55
  }: AppearanceSelectorProps) {
56
+ const translate = useOptionalT()
57
+ const previewHeading = translate ? translate('dictionaries.appearance.preview', 'Preview') : 'Preview'
55
58
  const normalizedIcon = icon ?? ''
56
59
  const normalizedColor = color ?? '#000000'
57
60
  const hasAppearance = Boolean(icon) || Boolean(color)
@@ -246,7 +249,7 @@ export function AppearanceSelector({
246
249
  </div>
247
250
 
248
251
  <div>
249
- <label className="text-sm font-medium">Preview</label>
252
+ <label className="text-sm font-medium">{previewHeading}</label>
250
253
  <div className="flex items-center gap-3 rounded border border-dashed border-border px-3 py-2">
251
254
  {hasPreviewContent ? (
252
255
  <>
@@ -392,7 +392,7 @@ export function DictionariesManager() {
392
392
 
393
393
  return (
394
394
  <div className="grid gap-6 lg:grid-cols-[320px_1fr]">
395
- <div className="rounded-lg border bg-card p-4 shadow-sm">
395
+ <div className="min-w-0 rounded-lg border bg-card p-4 shadow-sm">
396
396
  <div className="flex items-center justify-between">
397
397
  <h2 className="text-base font-semibold">
398
398
  {t('dictionaries.config.list.title', 'Dictionaries')}
@@ -477,7 +477,7 @@ export function DictionariesManager() {
477
477
  )}
478
478
  </div>
479
479
  </div>
480
- <div>
480
+ <div className="min-w-0">
481
481
  {selectedDictionary ? (
482
482
  <DictionaryEntriesEditor
483
483
  dictionaryId={selectedDictionary.id}
@@ -409,7 +409,7 @@ export function DictionaryEntriesEditor({ dictionaryId, dictionaryName, readOnly
409
409
  </div>
410
410
  </div>
411
411
 
412
- <div className="overflow-hidden rounded-md border">
412
+ <div className="overflow-x-auto rounded-md border">
413
413
  <Table>
414
414
  <TableHeader>
415
415
  <TableRow>
@@ -1,4 +1,5 @@
1
1
  {
2
+ "dictionaries.appearance.preview": "Vorschau",
2
3
  "dictionaries.config.delete.confirm": "Wörterbuch \"{{name}}\" löschen?",
3
4
  "dictionaries.config.dialog.cancel": "Abbrechen",
4
5
  "dictionaries.config.dialog.createTitle": "Wörterbuch erstellen",
@@ -1,4 +1,5 @@
1
1
  {
2
+ "dictionaries.appearance.preview": "Preview",
2
3
  "dictionaries.config.delete.confirm": "Delete dictionary \"{{name}}\"?",
3
4
  "dictionaries.config.dialog.cancel": "Cancel",
4
5
  "dictionaries.config.dialog.createTitle": "Create dictionary",
@@ -1,4 +1,5 @@
1
1
  {
2
+ "dictionaries.appearance.preview": "Vista previa",
2
3
  "dictionaries.config.delete.confirm": "¿Eliminar el diccionario \"{{name}}\"?",
3
4
  "dictionaries.config.dialog.cancel": "Cancelar",
4
5
  "dictionaries.config.dialog.createTitle": "Crear diccionario",
@@ -1,4 +1,5 @@
1
1
  {
2
+ "dictionaries.appearance.preview": "미리보기",
2
3
  "dictionaries.config.delete.confirm": "사전 \"{{name}}\"을(를) 삭제하시겠습니까?",
3
4
  "dictionaries.config.dialog.cancel": "취소",
4
5
  "dictionaries.config.dialog.createTitle": "사전 생성",
@@ -1,4 +1,5 @@
1
1
  {
2
+ "dictionaries.appearance.preview": "Podgląd",
2
3
  "dictionaries.config.delete.confirm": "Usunąć słownik \"{{name}}\"?",
3
4
  "dictionaries.config.dialog.cancel": "Anuluj",
4
5
  "dictionaries.config.dialog.createTitle": "Utwórz słownik",