Skip to content
Closed
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
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"vue-color": "^2.8.1",
"vue-d3-sunburst": "git+https://github.com/ErikBjare/Vue.D3.sunburst.git#patch-1",
"vue-datetime": "^1.0.0-beta.13",
"vue-i18n": "^8.28.2",
"vuedraggable": "^2.24.3",
"weekstart": "^1.0.1",
"xss": "^1.0.14"
Expand Down
36 changes: 18 additions & 18 deletions src/components/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,34 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
b-nav-item(v-if="activityViews && activityViews.length === 1", v-for="view in activityViews", :key="view.name", :to="view.pathUrl")
div.px-2.px-lg-1
icon(name="calendar-day")
| Activity
| {{ $t('nav.activity') }}

// If multiple (or no) activity views are available
b-nav-item-dropdown(v-if="!activityViews || activityViews.length !== 1")
template(slot="button-content")
div.d-inline.px-2.px-lg-1
icon(name="calendar-day")
| Activity
| {{ $t('nav.activity') }}
b-dropdown-item(v-if="activityViews === null", disabled)
span.text-muted Loading...
span.text-muted {{ $t('nav.loading') }}
br
b-dropdown-item(v-else-if="activityViews && activityViews.length <= 0", disabled)
| No activity reports available
| {{ $t('nav.noActivityReports') }}
br
small Make sure you have both an AFK and window watcher running
small {{ $t('nav.noActivityReportsHint') }}
b-dropdown-item(v-for="view in activityViews", :key="view.name", :to="view.pathUrl")
icon(:name="view.icon")
| {{ view.name }}

b-nav-item(to="/timeline" style="font-color: #000;")
div.px-2.px-lg-1
icon(name="stream")
| Timeline
| {{ $t('nav.timeline') }}

b-nav-item(to="/stopwatch")
div.px-2.px-lg-1
icon(name="stopwatch")
| Stopwatch
| {{ $t('nav.stopwatch') }}

// Brand on large screens (centered)
b-navbar-nav.abs-center.d-none.d-lg-block
Expand All @@ -55,41 +55,41 @@ div(:class="{'fixed-top-padding': fixedTopMenu}")
template(slot="button-content")
div.d-inline.px-2.px-lg-1
icon(name="tools")
| Tools
| {{ $t('nav.tools') }}
b-dropdown-item(to="/search")
icon(name="search")
| Search
| {{ $t('nav.search') }}
b-dropdown-item(to="/work-report")
icon(name="briefcase")
| Work Report
| {{ $t('nav.workReport') }}
b-dropdown-item(to="/trends" v-if="devmode")
icon(name="chart-line")
| Trends
| {{ $t('nav.trends') }}
b-dropdown-item(to="/report" v-if="devmode")
icon(name="chart-pie")
| Report
| {{ $t('nav.report') }}
b-dropdown-item(to="/alerts" v-if="devmode")
icon(name="flag-checkered")
| Alerts
| {{ $t('nav.alerts') }}
b-dropdown-item(to="/timespiral" v-if="devmode")
icon(name="history")
| Timespiral
| {{ $t('nav.timespiral') }}
b-dropdown-item(to="/query")
icon(name="code")
| Query
| {{ $t('nav.query') }}
b-dropdown-item(to="/graph" v-if="devmode")
// TODO: use circle-nodes instead in the future
icon(name="project-diagram")
| Graph
| {{ $t('nav.graph') }}

b-nav-item(to="/buckets")
div.px-2.px-lg-1
icon(name="database")
| Raw Data
| {{ $t('nav.rawData') }}
b-nav-item(to="/settings")
div.px-2.px-lg-1
icon(name="cog")
| Settings
| {{ $t('nav.settings') }}
</template>

<style lang="scss" scoped>
Expand Down
51 changes: 51 additions & 0 deletions src/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import en from './locales/en.json';
import zhCN from './locales/zh-CN.json';

Vue.use(VueI18n);

const LOCALE_KEY = 'aw-locale';

export const SUPPORTED_LOCALES = [
{ code: 'en', label: 'English' },
{ code: 'zh-CN', label: '中文(简体)' },
];

function isSupportedLocale(locale) {
return SUPPORTED_LOCALES.some(l => l.code === locale);
}

function readStoredLocale() {
try {
const locale = localStorage.getItem(LOCALE_KEY);
return isSupportedLocale(locale) ? locale : 'en';
} catch {
return 'en';
}
}

const savedLocale = readStoredLocale();

export const i18n = new VueI18n({
locale: savedLocale,
fallbackLocale: 'en',
messages: {
en,
'zh-CN': zhCN,
},
});

export function setLocale(locale) {
if (!isSupportedLocale(locale)) {
console.warn(`[i18n] Unsupported locale: ${locale}`);
return;
}

i18n.locale = locale;
try {
localStorage.setItem(LOCALE_KEY, locale);
} catch {
// Storage can be blocked in private browsing or hardened browser profiles.
}
}
Comment on lines +39 to +51

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 setLocale writes arbitrary strings to localStorage without validation. A tampered or corrupted aw-locale value (e.g. via browser devtools) is read back on page load and set as i18n.locale without checking whether the code is in SUPPORTED_LOCALES. While fallbackLocale: 'en' prevents a hard crash, invalid locale codes can still be persisted indefinitely and produce subtle translation-key resolution warnings in the console.

Suggested change
export function setLocale(locale) {
i18n.locale = locale;
localStorage.setItem(LOCALE_KEY, locale);
}
export function setLocale(locale) {
if (!SUPPORTED_LOCALES.some(l => l.code === locale)) {
console.warn(`[i18n] Unsupported locale: ${locale}`);
return;
}
i18n.locale = locale;
try {
localStorage.setItem(LOCALE_KEY, locale);
} catch {
// localStorage unavailable
}
}

73 changes: 73 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"nav": {
"activity": "Activity",
"timeline": "Timeline",
"stopwatch": "Stopwatch",
"tools": "Tools",
"search": "Search",
"workReport": "Work Report",
"trends": "Trends",
"report": "Report",
"alerts": "Alerts",
"timespiral": "Timespiral",
"query": "Query",
"graph": "Graph",
"rawData": "Raw Data",
"settings": "Settings",
"noActivityReports": "No activity reports available",
"noActivityReportsHint": "Make sure you have both an AFK and window watcher running",
"loading": "Loading..."
},
"home": {
"title": "Welcome to ActivityWatch",
"survey": "Fill out our user survey",
"voteFeatures": "vote on features on the forum",
"spreadWord": "Spread the word",
"support": "Support us!"
},
"timeline": {
"title": "Timeline",
"filters": "Filters",
"host": "Host:",
"client": "Client:",
"duration": "Duration:",
"afk": "AFK:",
"all": "All",
"filterAFK": "AFK filtered",
"filterMerged": "merged by app",
"filterNone": "none",
"filterCategories": "{count} category | {count} categories",
"swimlaneNone": "None",
"swimlaneCategory": "Group by category",
"swimlaneBucketType": "Group by bucket type"
},
"buckets": {
"title": "Buckets",
"docsHint": "Are you looking to collect more data? Check out {link} for more watchers.",
"docsLinkText": "the docs",
"lastUpdated": "Last updated",
Comment on lines +46 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Dead translation keys — docsHint and docsLinkText are never used. The b-alert in Buckets.vue (line 5–7) still contains hardcoded English: "Are you looking to collect more data? Check out #[a(…) the docs] for more watchers." The corresponding keys in both en.json and zh-CN.json are defined but never called with $t(), so the alert is untranslated when the locale is Chinese. The same pattern affects several home.* keys (survey, voteFeatures, spreadWord, support) which appear in Home.vue as hardcoded strings.

"noEvents": "No events recorded yet",
"thisDevice": "this device"
},
"settings": {
"title": "Settings",
"groups": {
"general": "General",
"generalHelp": "Defaults that shape how time periods, the timeline, and landing page behave.",
"appearance": "Appearance",
"appearanceHelp": "Theme and visualization colors.",
"categorization": "Categorization",
"categorizationHelp": "Rules that classify events into categories, plus AFK/active-pattern overrides.",
"privacy": "Privacy",
"privacyHelp": "Filters that drop or redact sensitive event data before it is stored.",
"developer": "Developer",
"language": "Language",
"languageHelp": "Choose the display language for ActivityWatch."
}
},
"language": {
"label": "Language",
"en": "English",
"zh-CN": "中文(简体)"
}
}
73 changes: 73 additions & 0 deletions src/locales/zh-CN.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
{
"nav": {
"activity": "活动",
"timeline": "时间线",
"stopwatch": "秒表",
"tools": "工具",
"search": "搜索",
"workReport": "工作报告",
"trends": "趋势",
"report": "报告",
"alerts": "提醒",
"timespiral": "时间螺旋",
"query": "查询",
"graph": "图表",
"rawData": "原始数据",
"settings": "设置",
"noActivityReports": "暂无活动报告",
"noActivityReportsHint": "请确保 AFK 监控器和窗口监控器均已运行",
"loading": "加载中..."
},
"home": {
"title": "欢迎使用 ActivityWatch",
"survey": "填写用户调查",
"voteFeatures": "在论坛中为功能投票",
"spreadWord": "传播",
"support": "支持我们!"
},
"timeline": {
"title": "时间线",
"filters": "筛选",
"host": "主机:",
"client": "客户端:",
"duration": "持续时间:",
"afk": "离开状态:",
"all": "全部",
"filterAFK": "AFK 过滤",
"filterMerged": "按应用合并",
"filterNone": "无",
"filterCategories": "{count} 个分类",
"swimlaneNone": "无",
"swimlaneCategory": "按分类分组",
"swimlaneBucketType": "按数据桶类型分组"
},
"buckets": {
"title": "数据桶",
"docsHint": "想收集更多数据?请查阅 {link} 获取更多监控器。",
"docsLinkText": "文档",
"lastUpdated": "最后更新",
"noEvents": "暂无事件记录",
"thisDevice": "此设备"
},
"settings": {
"title": "设置",
"groups": {
"general": "通用",
"generalHelp": "影响时间段、时间线和落地页行为的默认设置。",
"appearance": "外观",
"appearanceHelp": "主题与可视化颜色。",
"categorization": "分类",
"categorizationHelp": "将事件分类的规则,以及 AFK/活跃模式覆盖设置。",
"privacy": "隐私",
"privacyHelp": "在存储前过滤或遮盖敏感事件数据的过滤器。",
"developer": "开发者",
"language": "语言",
"languageHelp": "选择 ActivityWatch 的界面语言。"
}
},
"language": {
"label": "语言",
"en": "English",
"zh-CN": "中文(简体)"
}
}
4 changes: 4 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,17 @@ Vue.prototype.$isAndroid = process.env.VUE_APP_ON_ANDROID;
import { createClient, getClient, configureClient } from './util/awclient';
createClient();

// Setup i18n
import { i18n } from './i18n';

// Setup Vue app
import App from './App.vue';
new Vue({
el: '#app',
router: router,
render: h => h(App),
pinia,
i18n,
});

// Set the $aw global
Expand Down
11 changes: 6 additions & 5 deletions src/views/Buckets.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<template lang="pug">
div
h3 Buckets
h3 {{ $t('buckets.title') }}

b-alert(show)
| Are you looking to collect more data? Check out #[a(href="https://docs.activitywatch.net/en/latest/watchers.html") the docs] for more watchers.
i18n(path="buckets.docsHint" tag="span")
a(place="link" href="https://docs.activitywatch.net/en/latest/watchers.html") {{ $t('buckets.docsLinkText') }}

// By device
b-card.bucket-card.mb-3(
Expand All @@ -17,17 +18,17 @@ div
icon.mr-2.text-secondary(v-else, name="desktop" scale="1.2")
div
span.font-weight-bold {{ device.hostname }}
b-badge.ml-2(v-if="serverStore.info.hostname == device.hostname" variant="info") this device
b-badge.ml-2(v-if="serverStore.info.hostname == device.hostname" variant="info") {{ $t('buckets.thisDevice') }}
div.small.text-muted(v-if="device.hostname !== device.device_id")
| ID: {{ device.id }}
div.small(v-if="deviceHasEvents(device)")
span.text-muted Last updated&nbsp;
span.text-muted {{ $t('buckets.lastUpdated') }}&nbsp;
time(:class="{'text-success': isRecent(device.last_updated)}",
:datetime="device.last_updated",
:title="device.last_updated")
| {{ device.last_updated | friendlytime }}
div.small.text-muted(v-else)
| No events recorded yet
| {{ $t('buckets.noEvents') }}
b-dropdown.kebab-dropdown(
size="sm",
variant="outline-secondary",
Expand Down
Loading
Loading