From 3a1920d104cda7a4f79a9b7ab263f3dbf9fc6969 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 14 Jul 2026 23:12:29 +0800 Subject: [PATCH] fix: localize journal candidate review --- plugins/journal/frontend/src/index.js | 56 ++++++++++++++++++++------- plugins/journal/locales/en.json | 32 ++++++++++++++- plugins/journal/locales/ru.json | 32 ++++++++++++++- scripts/smoke-journal-plugin.js | 24 +++++++++++- 4 files changed, 123 insertions(+), 21 deletions(-) diff --git a/plugins/journal/frontend/src/index.js b/plugins/journal/frontend/src/index.js index aaa6e58..9145647 100644 --- a/plugins/journal/frontend/src/index.js +++ b/plugins/journal/frontend/src/index.js @@ -363,6 +363,32 @@ modalHost.setAttribute('hidden', 'hidden'); } + function activityLabel(activity) { + var type = text(activity && activity.type); + var labels = { + 'browser.capture.link': ['ui.activity.captureLink', 'Link captured'], + 'browser.capture.selection': ['ui.activity.captureSelection', 'Selection captured'], + 'browser.capture.page': ['ui.activity.capturePage', 'Page captured'], + 'file.created': ['ui.activity.fileCreated', 'File created'], + 'file.updated': ['ui.activity.fileUpdated', 'File updated'], + 'file.deleted': ['ui.activity.fileDeleted', 'File deleted'], + 'note.saved': ['ui.activity.noteSaved', 'Note saved'], + 'workspace.created': ['ui.activity.dealCreated', 'Deal created'], + 'workspace.renamed': ['ui.activity.dealRenamed', 'Deal renamed'] + }; + var label = labels[type] || ['ui.activity.generic', 'Activity']; + return tr(label[0], null, label[1]); + } + + function entryMeta(entry) { + var facts = [entry.workspaceRootPath, entry.billable ? tr('ui.meta.billable', null, 'billable') : tr('ui.meta.nonBillable', null, 'non-billable')]; + if (entry.activityIds.length) { + facts.push(tr(entry.activityIds.length === 1 ? 'ui.meta.activities.one' : 'ui.meta.activities.many', { count: entry.activityIds.length }, entry.activityIds.length + ' linked activities')); + } + if (entry.sourceTodoId) facts.push(tr('ui.meta.todo', null, 'linked todo')); + return facts.join(' · '); + } + function showEntryModal(existingEntry, candidate, completedTodo) { var editing = !!existingEntry; var reviewingCandidate = !editing && !!candidate; @@ -406,22 +432,22 @@ } var candidateContext = reviewingCandidate ? el('div', { className: 'journal-candidate-context', 'data-journal-candidate': candidate.candidateId }, [ - el('strong', { textContent: 'Possible journal entry' }), - el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }), - el('div', { textContent: 'Time: ' + candidateTime(candidate.startedAt) + ' - ' + candidateTime(candidate.endedAt) }), - el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }), - el('div', { textContent: 'Activities: ' + candidate.activityCount }) + el('strong', { textContent: tr('ui.candidate.title', null, 'Possible journal entry') }), + el('div', { textContent: tr('ui.candidate.deal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }), + el('div', { textContent: tr('ui.candidate.time', { start: candidateTime(candidate.startedAt), end: candidateTime(candidate.endedAt) }, 'Time: ' + candidateTime(candidate.startedAt) + ' – ' + candidateTime(candidate.endedAt)) }), + el('div', { textContent: tr('ui.candidate.duration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }), + el('div', { textContent: tr('ui.candidate.activities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) }) ]) : null; var candidateActivities = reviewingCandidate ? el('fieldset', { className: 'journal-candidate-activities' }, [ - el('legend', { textContent: 'Linked activities' }) + el('legend', { textContent: tr('ui.candidate.linkedActivities', null, 'Linked activities') }) ].concat(activityInputs.map(function (item) { - var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + item.activity.type + ' · ' + item.activity.activityId; + var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + activityLabel(item.activity); return el('label', { className: 'journal-candidate-activity' }, [item.input, detail]); }))) : null; var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [ - el('strong', { textContent: 'Completed todo' }), - el('div', { textContent: 'Workspace: ' + completedTodo.workspaceRootPath }), - completedTodo.completedAt ? el('div', { textContent: 'Completed: ' + candidateTime(completedTodo.completedAt) }) : null + el('strong', { textContent: tr('ui.todo.title', null, 'Completed todo') }), + el('div', { textContent: tr('ui.candidate.deal', { deal: completedTodo.workspaceRootPath }, 'Deal: ' + completedTodo.workspaceRootPath) }), + completedTodo.completedAt ? el('div', { textContent: tr('ui.todo.completed', { time: candidateTime(completedTodo.completedAt) }, 'Completed: ' + candidateTime(completedTodo.completedAt)) }) : null ]) : null; modalHost.innerHTML = ''; @@ -467,13 +493,13 @@ var handledThrough = text(formValue && formValue.handledThrough).trim(); var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim(); if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) { - statusText = 'A journal entry already references this candidate'; + statusText = tr('ui.candidate.duplicate', null, 'A journal entry already references this candidate'); statusClass = 'error'; render(); return; } if (!existingEntry && sourceTodoId && entries.some(function (entry) { return entry.sourceTodoId === sourceTodoId; })) { - statusText = 'A journal entry already references this todo'; + statusText = tr('ui.todo.duplicate', null, 'A journal entry already references this todo'); statusClass = 'error'; render(); return; @@ -500,7 +526,7 @@ entries = sortEntries(entries); var targetEntries = entries.filter(function (item) { return item.workspaceRootPath === workspaceRoot; }); closeEntryModal(); - statusText = existingEntry ? 'Entry updated' : 'Entry added'; + statusText = existingEntry ? tr('ui.updated', null, 'Entry updated') : tr('ui.added', null, 'Entry added'); statusClass = ''; persist(workspaceRoot, targetEntries).then(function () { if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined; @@ -535,9 +561,9 @@ el('div', { className: 'journal-main' }, [ el('div', { className: 'journal-entry-title', textContent: entry.title }), entry.summary ? el('div', { className: 'journal-summary', textContent: entry.summary }) : null, - el('div', { className: 'journal-meta', textContent: entry.workspaceRootPath + (entry.billable ? ' · billable' : ' · non-billable') + (entry.activityIds.length ? ' · ' + entry.activityIds.length + ' linked activities' : '') + (entry.sourceTodoId ? ' · linked todo' : '') }) + el('div', { className: 'journal-meta', textContent: entryMeta(entry) }) ]), - el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }), + el('div', { className: 'journal-minutes', textContent: tr('ui.minutesValue', { minutes: entry.minutes }, entry.minutes + ' min') }), scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [ el('button', { className: 'journal-icon-btn', type: 'button', title: tr('ui.edit', null, 'Edit'), 'aria-label': tr('ui.edit', null, 'Edit'), 'data-journal-action': 'edit', innerHTML: iconSvg('edit'), onClick: function () { showEntryModal(entry); } }), el('button', { className: 'journal-icon-btn danger', type: 'button', title: tr('ui.delete', null, 'Delete'), 'aria-label': tr('ui.delete', null, 'Delete'), 'data-journal-action': 'delete', innerHTML: iconSvg('trash'), onClick: function () { deleteEntry(entry); } }) diff --git a/plugins/journal/locales/en.json b/plugins/journal/locales/en.json index 80d3e29..902f0d8 100644 --- a/plugins/journal/locales/en.json +++ b/plugins/journal/locales/en.json @@ -1,6 +1,6 @@ { "manifest.name": "Journal", - "manifest.description": "Workspace-scoped journal with user-authored entries and optional Activity links.", + "manifest.description": "Deal-scoped journal with user-authored entries and optional Activity links.", "contributions.views.verstak.journal.view.title": "Journal", "contributions.sidebarItems.verstak.journal.sidebar.title": "Journal", "contributions.workspaceItems.verstak.journal.workspace.title": "Journal", @@ -31,5 +31,33 @@ "ui.edit": "Edit", "ui.delete": "Delete", "ui.entryCount.one": "{count} entry", - "ui.entryCount.many": "{count} entries" + "ui.entryCount.many": "{count} entries", + "ui.candidate.title": "Possible journal entry", + "ui.candidate.deal": "Deal: {deal}", + "ui.candidate.time": "Time: {start} – {end}", + "ui.candidate.duration": "Estimated duration: {minutes} min", + "ui.candidate.activities": "Activities: {count}", + "ui.candidate.linkedActivities": "Linked activities", + "ui.candidate.duplicate": "A journal entry already references this candidate", + "ui.todo.title": "Completed todo", + "ui.todo.completed": "Completed: {time}", + "ui.todo.duplicate": "A journal entry already references this todo", + "ui.activity.captureLink": "Link captured", + "ui.activity.captureSelection": "Selection captured", + "ui.activity.capturePage": "Page captured", + "ui.activity.fileCreated": "File created", + "ui.activity.fileUpdated": "File updated", + "ui.activity.fileDeleted": "File deleted", + "ui.activity.noteSaved": "Note saved", + "ui.activity.dealCreated": "Deal created", + "ui.activity.dealRenamed": "Deal renamed", + "ui.activity.generic": "Activity", + "ui.updated": "Entry updated", + "ui.added": "Entry added", + "ui.minutesValue": "{minutes} min", + "ui.meta.billable": "billable", + "ui.meta.nonBillable": "non-billable", + "ui.meta.activities.one": "{count} linked activity", + "ui.meta.activities.many": "{count} linked activities", + "ui.meta.todo": "linked todo" } diff --git a/plugins/journal/locales/ru.json b/plugins/journal/locales/ru.json index 60fe0a0..03f4168 100644 --- a/plugins/journal/locales/ru.json +++ b/plugins/journal/locales/ru.json @@ -1,6 +1,6 @@ { "manifest.name": "Журнал", - "manifest.description": "Журнал рабочего пространства с ручными записями и необязательными связями с активностью.", + "manifest.description": "Журнал Дела с ручными записями и необязательными связями с активностью.", "contributions.views.verstak.journal.view.title": "Журнал", "contributions.sidebarItems.verstak.journal.sidebar.title": "Журнал", "contributions.workspaceItems.verstak.journal.workspace.title": "Журнал", @@ -31,5 +31,33 @@ "ui.edit": "Изменить", "ui.delete": "Удалить", "ui.entryCount.one": "{count} запись", - "ui.entryCount.many": "Записей: {count}" + "ui.entryCount.many": "Записей: {count}", + "ui.candidate.title": "Возможная запись журнала", + "ui.candidate.deal": "Дело: {deal}", + "ui.candidate.time": "Время: {start} – {end}", + "ui.candidate.duration": "Расчётная длительность: {minutes} мин", + "ui.candidate.activities": "Активностей: {count}", + "ui.candidate.linkedActivities": "Связанные активности", + "ui.candidate.duplicate": "Запись журнала уже ссылается на этого кандидата", + "ui.todo.title": "Выполненная задача", + "ui.todo.completed": "Выполнена: {time}", + "ui.todo.duplicate": "Запись журнала уже ссылается на эту задачу", + "ui.activity.captureLink": "Сохранена ссылка", + "ui.activity.captureSelection": "Захвачено выделение", + "ui.activity.capturePage": "Сохранена страница", + "ui.activity.fileCreated": "Создан файл", + "ui.activity.fileUpdated": "Изменён файл", + "ui.activity.fileDeleted": "Удалён файл", + "ui.activity.noteSaved": "Сохранена заметка", + "ui.activity.dealCreated": "Создано Дело", + "ui.activity.dealRenamed": "Переименовано Дело", + "ui.activity.generic": "Активность", + "ui.updated": "Запись обновлена", + "ui.added": "Запись добавлена", + "ui.minutesValue": "{minutes} мин", + "ui.meta.billable": "оплачиваемая", + "ui.meta.nonBillable": "неоплачиваемая", + "ui.meta.activities.one": "{count} связанная активность", + "ui.meta.activities.many": "Связанных активностей: {count}", + "ui.meta.todo": "связанная задача" } diff --git a/scripts/smoke-journal-plugin.js b/scripts/smoke-journal-plugin.js index fe65df2..a657611 100644 --- a/scripts/smoke-journal-plugin.js +++ b/scripts/smoke-journal-plugin.js @@ -6,12 +6,14 @@ const vm = require('vm'); const root = path.resolve(__dirname, '..'); const manifestPath = path.join(root, 'plugins', 'journal', 'plugin.json'); const sourcePath = path.join(root, 'plugins', 'journal', 'frontend', 'src', 'index.js'); +const russianLocalePath = path.join(root, 'plugins', 'journal', 'locales', 'ru.json'); if (!fs.existsSync(manifestPath)) throw new Error('journal plugin manifest missing'); if (!fs.existsSync(sourcePath)) throw new Error('journal frontend entry missing'); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const source = fs.readFileSync(sourcePath, 'utf8'); +const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8')); class FakeNode { constructor(tagName) { @@ -131,7 +133,7 @@ function loadComponent(document) { return component; } -function makeApi(initialSettings = {}) { +function makeApi(initialSettings = {}, locale = null) { const settings = { ...initialSettings }; const publishedEvents = []; return { @@ -154,6 +156,11 @@ function makeApi(initialSettings = {}) { { type: 'folder', relativePath: 'Client', name: 'Client' }, ], }, + i18n: locale ? { + t(key, params, fallback) { + return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? '')); + }, + } : null, storedEntries(key) { return settings[key] || []; }, @@ -245,8 +252,11 @@ function byData(container, attr, value) { toolRequest: { type: 'work-session-candidate', candidate }, }); if (!candidateView.container.textContent.includes('Review possible journal entry')) throw new Error('candidate review modal was not opened'); - if (!candidateView.container.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not shown for review'); + if (!candidateView.container.textContent.includes('Deal: Project')) throw new Error('candidate Deal was not shown for review'); if (!candidateView.container.textContent.includes('Estimated duration: 51 min')) throw new Error('candidate duration was not shown for review'); + if (candidateView.container.textContent.includes('browser.capture.selection') || candidateView.container.textContent.includes('verstak.browser-inbox') || candidateView.container.textContent.includes('capture-1')) { + throw new Error('candidate review exposed technical Activity identifiers'); + } if (byData(candidateView.container, 'data-journal-input', 'title').value !== '') throw new Error('candidate review must start with an empty title'); if (byData(candidateView.container, 'data-journal-input', 'summary').value !== '') throw new Error('candidate review must start with an empty body'); if (byData(candidateView.container, 'data-journal-input', 'minutes').value !== '51') throw new Error('candidate review must prefill the factual duration'); @@ -273,6 +283,15 @@ function byData(container, attr, value) { throw new Error('journal rows must not navigate to Activity by default'); } + const russianCandidateView = await mountWithApi(makeApi({}, russianLocale), { + workspaceNode: { name: 'Project' }, + workspaceRootPath: 'Project', + toolRequest: { type: 'work-session-candidate', candidate }, + }); + if (!russianCandidateView.container.textContent.includes('Дело: Project') || !russianCandidateView.container.textContent.includes('Захвачено выделение')) { + throw new Error('candidate review was not localized'); + } + byData(candidateView.container, 'data-journal-action', 'delete').click(); await flush(); if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted'); @@ -350,6 +369,7 @@ function byData(container, attr, value) { component.unmount && component.unmount(container); component.unmount && component.unmount(candidateView.container); + component.unmount && component.unmount(russianCandidateView.container); component.unmount && component.unmount(todoView.container); component.unmount && component.unmount(duplicateTodoView.container); component.unmount && component.unmount(globalView.container);