dsh-niuma 0.2.2 → 0.2.3

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  本文件记录用户可感知的变化。版本号遵循语义化版本。
4
4
 
5
+ ## 0.2.3 — 2026-10-22
6
+
7
+ ### 新增:版本号显示 + 自动更新机制
8
+
9
+ - **面板底部显示版本号**:`牛马加速器 v0.2.x`(取自宿主 package.json,固定放最底部)
10
+ - **自动检查更新**:宿主启动 8 秒后查一次 npm `dsh-niuma@latest`;有新版本自动下载 tarball 解包到
11
+ `$DSH_HOME/niuma/updates/<version>/`,**下次启动 DSH 自动覆盖安装并生效**
12
+ - **手动更新**:面板发现新版本时显示「发现新版本 vX · 立即更新」按钮,点击即下载(重启生效)
13
+ - **安全守卫**:
14
+ - git 开发工作区(存在 `.git`)不自动更新,避免覆盖未提交代码
15
+ - `NIUMA_UPDATE_DISABLED=1` 可整体关闭
16
+ - 网络失败只写日志,面板继续用当前版本,不影响计时
17
+ - 版本比较用内部 semver 实现,不引第三方依赖
18
+ - 新增 `GET/POST /niuma/update` 路由;`GET /niuma/config` 载荷增加 `version`
19
+ - 主金额改为纯文本渲染(逐位轮盘在面板滚动容器里会出现重影);「加速」= 毫秒级刷新(rAF),
20
+ 末位小数每帧高速变化,视觉上就是数字持续快速上涨
21
+ - 新增 `lib/update.js`(semver 比较、tarball 解包、暂存/应用)与 `tools/verify-update.mjs`(27 项断言)
22
+
5
23
  ## 0.2.2 — 2026-10-22
6
24
 
7
25
  ### 新增:主金额小数位 + 刷新频率 + 滚动数字/加速
package/README.md CHANGED
@@ -32,7 +32,8 @@
32
32
  - 节假日数据内置在插件中(2026 官方 / 2027 预估),设置区不再需要手工维护一长串日期;2027 官方安排公布后随插件更新
33
33
  - 午休可设置起止,**可勾选午休是否算工时**;午休不算工时则午休期间金额冻结
34
34
  - 状态徽标:休息日 / 未上班 / 搬砖中 / 午休中 / 已下班
35
- - 主金额是**连续里程表滚动**:小数末位飞快转、前一位慢 10 倍、整数位慢慢进位;勾选「加速」按屏幕刷新率每帧滚动
35
+ - 面板显示版本号;自动检查 npm 新版本,下载后**重启 DSH 自动生效**(git 开发工作区不覆盖)
36
+ - **加速**:勾选后按屏幕刷新率(≈16ms)刷新,末位小数高速跳动、数字快速上涨;不勾则按刷新频率步进
36
37
  - 主金额**小数位可选(0–6)**,右侧下拉即时生效
37
38
  - **刷新频率可选**:按秒 / 250ms / 100ms / 50ms / 屏幕刷新率(≈16ms 连续滚动)
38
39
  - 今日进度条、距上班/距下班倒计时
Binary file
package/lib/client.js CHANGED
@@ -558,14 +558,6 @@ var CARD = {
558
558
  boxSizing: "border-box"
559
559
  };
560
560
  var NUM = { fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" };
561
- var GOLD = {
562
- color: "#D9A441",
563
- backgroundImage: "linear-gradient(180deg, #FFE9A8 0%, #F2C14E 40%, #D79A2B 72%, #B87A16 100%)",
564
- WebkitBackgroundClip: "text",
565
- backgroundClip: "text",
566
- WebkitTextFillColor: "transparent",
567
- textShadow: "0 1px 0 rgba(0,0,0,.05)"
568
- };
569
561
  var GOLD_TINT = "#E7B84D";
570
562
  var STATUS_META = {
571
563
  rest: { key: "statusRest", color: C.label3 },
@@ -612,6 +604,10 @@ var zh = {
612
604
  "decimalsLabel": "\u5C0F\u6570",
613
605
  "decimalsUnit": "\u4F4D",
614
606
  "turboLabel": "\u52A0\u901F",
607
+ "updateAvailable": "\u53D1\u73B0\u65B0\u7248\u672C v{version}",
608
+ "updateNow": "\u7ACB\u5373\u66F4\u65B0",
609
+ "updating": "\u6B63\u5728\u4E0B\u8F7D\u65B0\u7248\u672C\u2026",
610
+ "updateStaged": "v{version} \u5DF2\u4E0B\u8F7D \xB7 \u91CD\u542F DSH \u751F\u6548",
615
611
  "refreshRate": "\u5237\u65B0\u9891\u7387",
616
612
  "rateSecond": "\u6309\u79D2\uFF081s\uFF09",
617
613
  "rateFrame": "\u5237\u65B0\u7387\uFF08\u224816ms\uFF09",
@@ -716,6 +712,10 @@ var en = {
716
712
  "decimalsLabel": "Decimals",
717
713
  "decimalsUnit": "dp",
718
714
  "turboLabel": "Turbo",
715
+ "updateAvailable": "New version v{version}",
716
+ "updateNow": "Update now",
717
+ "updating": "Downloading update\u2026",
718
+ "updateStaged": "v{version} downloaded \xB7 restart DSH to apply",
719
719
  "refreshRate": "Refresh rate",
720
720
  "rateSecond": "Per second (1s)",
721
721
  "rateFrame": "Display rate (~16ms)",
@@ -846,6 +846,35 @@ function useRemoteConfig() {
846
846
  const refresh = import_react.default.useCallback(() => setTick((n) => n + 1), []);
847
847
  return { ...state, refresh };
848
848
  }
849
+ function useUpdateInfo() {
850
+ const [state, setState] = import_react.default.useState({ status: "loading", data: null, error: null });
851
+ const load = import_react.default.useCallback(() => {
852
+ if (typeof fetch !== "function") {
853
+ setState({ status: "failed", data: null, error: "fetch unavailable" });
854
+ return;
855
+ }
856
+ fetch(`${ROUTE_BASE}/update`, { cache: "no-store", credentials: "same-origin" }).then(async (response) => {
857
+ const body = await response.json();
858
+ if (!response.ok || !body || body.ok !== true) throw new Error(body && body.error || `HTTP ${response.status}`);
859
+ return body.data || null;
860
+ }).then((data) => setState({ status: "ready", data, error: null })).catch((error) => setState({ status: "failed", data: null, error: String(error && error.message || error) }));
861
+ }, []);
862
+ import_react.default.useEffect(() => {
863
+ load();
864
+ const timer = setInterval(load, 6 * 3600 * 1e3);
865
+ return () => clearInterval(timer);
866
+ }, [load]);
867
+ const download = import_react.default.useCallback(() => {
868
+ setState((prev) => ({ ...prev, status: "updating" }));
869
+ if (typeof fetch !== "function") return;
870
+ fetch(`${ROUTE_BASE}/update`, { method: "POST", credentials: "same-origin" }).then(async (response) => {
871
+ const body = await response.json();
872
+ if (!response.ok || !body || body.ok !== true) throw new Error(body && body.error || `HTTP ${response.status}`);
873
+ return body.data || null;
874
+ }).then((data) => setState({ status: "ready", data, error: null })).catch((error) => setState({ status: "failed", data: null, error: String(error && error.message || error) }));
875
+ }, []);
876
+ return { ...state, reload: load, download };
877
+ }
849
878
  function useClock(updateMs) {
850
879
  const [now, setNow] = import_react.default.useState(() => /* @__PURE__ */ new Date());
851
880
  import_react.default.useEffect(() => {
@@ -936,97 +965,17 @@ function SectionTitle(props) {
936
965
  props.hint ? import_react.default.createElement("span", { key: "h", style: { fontSize: 10.5, color: C.label3, ...NUM } }, props.hint) : null
937
966
  ]);
938
967
  }
939
- var ROLL_BLOCKS = Array.from({ length: 11 }, (_, i) => String(i % 10));
940
- function RollingDigit(props) {
941
- const heightEm = 1.22;
942
- const widthEm = 0.62;
943
- const pos = Number.isFinite(props.position) ? props.position : 0;
944
- const wheel = (pos % 10 + 10) % 10;
945
- return import_react.default.createElement("span", {
946
- "aria-hidden": "true",
947
- style: {
948
- display: "inline-block",
949
- position: "relative",
950
- overflow: "hidden",
951
- width: `${widthEm}em`,
952
- height: `${heightEm}em`,
953
- verticalAlign: "baseline"
954
- }
955
- }, [
956
- import_react.default.createElement("span", {
957
- key: "ghost",
958
- style: { visibility: "hidden", display: "block", height: `${heightEm}em`, lineHeight: `${heightEm}em` }
959
- }, "0"),
960
- import_react.default.createElement("span", {
961
- key: "strip",
962
- style: {
963
- position: "absolute",
964
- left: 0,
965
- top: 0,
966
- display: "flex",
967
- flexDirection: "column",
968
- transform: `translateY(${-(wheel * heightEm)}em)`,
969
- willChange: "transform"
970
- }
971
- }, ROLL_BLOCKS.map((value, i) => import_react.default.createElement("span", {
972
- key: i,
973
- style: { display: "block", height: `${heightEm}em`, lineHeight: `${heightEm}em` }
974
- }, value)))
975
- ]);
976
- }
977
968
  function RollingMoney(props) {
978
969
  const decimals = Math.max(0, Math.min(6, Number.isInteger(props.decimals) ? props.decimals : 4));
979
- const text = formatMoney(props.value, decimals);
980
- const scale = 10 ** decimals;
981
- const chars = text.split("");
982
- const significances = new Array(chars.length).fill(-1);
983
- let seen = 0;
984
- for (let i = chars.length - 1; i >= 0; i -= 1) {
985
- if (/[0-9]/.test(chars[i])) {
986
- significances[i] = seen;
987
- seen += 1;
988
- }
989
- }
990
970
  return import_react.default.createElement("span", {
991
971
  style: {
992
- display: "inline-flex",
993
- alignItems: "baseline",
994
- letterSpacing: 0,
995
- position: "relative",
996
- // 等宽字体保证每个数字轮宽度稳定(不裁字、不抖动)
997
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
972
+ display: "inline-block",
973
+ whiteSpace: "nowrap",
974
+ letterSpacing: "-1.2px",
975
+ fontVariantNumeric: "tabular-nums",
998
976
  ...props.style || {}
999
977
  }
1000
- }, [
1001
- import_react.default.createElement("span", {
1002
- key: "sr",
1003
- style: {
1004
- position: "absolute",
1005
- width: 1,
1006
- height: 1,
1007
- padding: 0,
1008
- margin: -1,
1009
- overflow: "hidden",
1010
- clip: "rect(0,0,0,0)",
1011
- whiteSpace: "nowrap",
1012
- border: 0
1013
- }
1014
- }, text),
1015
- import_react.default.createElement("span", {
1016
- key: "visual",
1017
- "aria-hidden": "true",
1018
- style: { display: "inline-flex", alignItems: "baseline" }
1019
- }, chars.map((char, i) => {
1020
- if (!/[0-9]/.test(char)) {
1021
- return import_react.default.createElement("span", {
1022
- key: `s${i}`,
1023
- style: { display: "inline-block", lineHeight: "1.22em" }
1024
- }, char);
1025
- }
1026
- const position = (Number(props.value) || 0) * scale / 10 ** significances[i];
1027
- return import_react.default.createElement(RollingDigit, { key: `d${i}`, position });
1028
- }))
1029
- ]);
978
+ }, formatMoney(props.value, decimals));
1030
979
  }
1031
980
  function HeroCard(props) {
1032
981
  const t = props.t;
@@ -1083,11 +1032,70 @@ function HeroCard(props) {
1083
1032
  }) : null,
1084
1033
  t("earnedToday")
1085
1034
  ]),
1086
- import_react.default.createElement(
1087
- "span",
1088
- { key: "b", style: { fontSize: 11, color: C.label3, ...NUM } },
1089
- `${t("todayOn")} ${snap.dateKey}`
1090
- )
1035
+ import_react.default.createElement("div", {
1036
+ key: "b",
1037
+ style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4 }
1038
+ }, [
1039
+ import_react.default.createElement(
1040
+ "span",
1041
+ { key: "d", style: { fontSize: 11, color: C.label3, ...NUM } },
1042
+ `${t("todayOn")} ${snap.dateKey}`
1043
+ ),
1044
+ import_react.default.createElement("div", { key: "c", style: { display: "inline-flex", alignItems: "center", gap: 6 } }, [
1045
+ import_react.default.createElement("label", {
1046
+ key: "dec",
1047
+ title: t("decimalsLabel"),
1048
+ style: { display: "inline-flex", alignItems: "center", gap: 3, fontSize: 10, color: C.label3, ...NUM }
1049
+ }, [
1050
+ import_react.default.createElement("span", { key: "l" }, t("decimalsLabel")),
1051
+ import_react.default.createElement("select", {
1052
+ key: "s",
1053
+ value: String(decimals),
1054
+ "aria-label": t("decimalsLabel"),
1055
+ onChange: (event) => onDecimalsChange && onDecimalsChange(Number(event.target.value)),
1056
+ style: {
1057
+ height: 22,
1058
+ padding: "0 2px",
1059
+ borderRadius: 6,
1060
+ border: `1px solid ${C.border1}`,
1061
+ background: C.layer2,
1062
+ color: C.label2,
1063
+ fontSize: 10.5,
1064
+ fontVariantNumeric: "tabular-nums",
1065
+ outline: "none",
1066
+ cursor: "pointer"
1067
+ }
1068
+ }, [0, 1, 2, 3, 4, 5, 6].map((n) => import_react.default.createElement("option", { key: n, value: String(n) }, String(n)))),
1069
+ import_react.default.createElement("span", { key: "u" }, t("decimalsUnit"))
1070
+ ]),
1071
+ import_react.default.createElement("label", {
1072
+ key: "turbo",
1073
+ title: t("turboLabel"),
1074
+ style: {
1075
+ display: "inline-flex",
1076
+ alignItems: "center",
1077
+ gap: 3,
1078
+ fontSize: 10,
1079
+ padding: "1px 6px",
1080
+ borderRadius: 999,
1081
+ border: `1px solid ${turbo ? `color-mix(in srgb, ${GOLD_TINT} 60%, ${C.border1})` : C.border1}`,
1082
+ background: turbo ? `color-mix(in srgb, ${GOLD_TINT} 16%, transparent)` : "transparent",
1083
+ color: turbo ? C.label2 : C.label3,
1084
+ cursor: "pointer",
1085
+ ...NUM
1086
+ }
1087
+ }, [
1088
+ import_react.default.createElement("input", {
1089
+ key: "i",
1090
+ type: "checkbox",
1091
+ checked: turbo,
1092
+ onChange: (event) => onTurboChange && onTurboChange(event.target.checked),
1093
+ style: { accentColor: GOLD_TINT, width: 12, height: 12, margin: 0, cursor: "inherit" }
1094
+ }),
1095
+ import_react.default.createElement("span", { key: "l" }, t("turboLabel"))
1096
+ ])
1097
+ ])
1098
+ ])
1091
1099
  ]),
1092
1100
  import_react.default.createElement("div", {
1093
1101
  key: "amt",
@@ -1101,7 +1109,7 @@ function HeroCard(props) {
1101
1109
  fontSize: 46,
1102
1110
  lineHeight: 1.12,
1103
1111
  fontWeight: 800,
1104
- ...GOLD,
1112
+ color: "#D9A441",
1105
1113
  ...NUM,
1106
1114
  ...rest ? { opacity: 0.62 } : {}
1107
1115
  }
@@ -1109,67 +1117,7 @@ function HeroCard(props) {
1109
1117
  import_react.default.createElement("span", {
1110
1118
  key: "unit",
1111
1119
  style: { fontSize: 18, fontWeight: 700, color: C.label2, ...NUM }
1112
- }, "\u5143"),
1113
- import_react.default.createElement("label", {
1114
- key: "dec",
1115
- title: t("decimalsLabel"),
1116
- style: {
1117
- marginLeft: "auto",
1118
- display: "inline-flex",
1119
- alignItems: "center",
1120
- gap: 3,
1121
- fontSize: 10,
1122
- color: C.label3,
1123
- ...NUM
1124
- }
1125
- }, [
1126
- import_react.default.createElement("span", { key: "l" }, t("decimalsLabel")),
1127
- import_react.default.createElement("select", {
1128
- key: "s",
1129
- value: String(decimals),
1130
- "aria-label": t("decimalsLabel"),
1131
- onChange: (event) => onDecimalsChange && onDecimalsChange(Number(event.target.value)),
1132
- style: {
1133
- height: 22,
1134
- padding: "0 2px",
1135
- borderRadius: 6,
1136
- border: `1px solid ${C.border1}`,
1137
- background: C.layer2,
1138
- color: C.label2,
1139
- fontSize: 10.5,
1140
- fontVariantNumeric: "tabular-nums",
1141
- outline: "none",
1142
- cursor: "pointer"
1143
- }
1144
- }, [0, 1, 2, 3, 4, 5, 6].map((n) => import_react.default.createElement("option", { key: n, value: String(n) }, String(n)))),
1145
- import_react.default.createElement("span", { key: "u" }, t("decimalsUnit"))
1146
- ]),
1147
- import_react.default.createElement("label", {
1148
- key: "turbo",
1149
- title: t("turboLabel"),
1150
- style: {
1151
- display: "inline-flex",
1152
- alignItems: "center",
1153
- gap: 3,
1154
- fontSize: 10,
1155
- padding: "1px 6px",
1156
- borderRadius: 999,
1157
- border: `1px solid ${turbo ? `color-mix(in srgb, ${GOLD_TINT} 60%, ${C.border1})` : C.border1}`,
1158
- background: turbo ? `color-mix(in srgb, ${GOLD_TINT} 16%, transparent)` : "transparent",
1159
- color: turbo ? C.label2 : C.label3,
1160
- cursor: "pointer",
1161
- ...NUM
1162
- }
1163
- }, [
1164
- import_react.default.createElement("input", {
1165
- key: "i",
1166
- type: "checkbox",
1167
- checked: turbo,
1168
- onChange: (event) => onTurboChange && onTurboChange(event.target.checked),
1169
- style: { accentColor: GOLD_TINT, width: 12, height: 12, margin: 0, cursor: "inherit" }
1170
- }),
1171
- import_react.default.createElement("span", { key: "l" }, t("turboLabel"))
1172
- ])
1120
+ }, "\u5143")
1173
1121
  ]),
1174
1122
  import_react.default.createElement("div", {
1175
1123
  key: "chips",
@@ -1715,6 +1663,7 @@ function SettingsSection(props) {
1715
1663
  function NiumaPanel(props) {
1716
1664
  const t = props && typeof props.t === "function" ? props.t : fallbackTranslate;
1717
1665
  const remote = useRemoteConfig();
1666
+ const update = useUpdateInfo();
1718
1667
  const cfg = import_react.default.useMemo(() => {
1719
1668
  const raw = remote.data && remote.data.config ? remote.data.config : null;
1720
1669
  if (!raw) return DEFAULTS;
@@ -1839,6 +1788,7 @@ function NiumaPanel(props) {
1839
1788
  const onDecimalsChange = (value) => postConfig({ decimals: value });
1840
1789
  const onTurboChange = (checked) => postConfig({ turbo: Boolean(checked) });
1841
1790
  const holidaySource = remote.data && remote.data.holidaySource || HOLIDAY_SOURCE;
1791
+ const versionText = remote.data && remote.data.version || update.data && update.data.currentVersion || "";
1842
1792
  const banner = remote.status === "failed" ? {
1843
1793
  color: C.warn,
1844
1794
  text: `${t("configFailed")}${remote.error ? `\uFF08${remote.error}\uFF09` : ""}`,
@@ -1940,6 +1890,67 @@ function NiumaPanel(props) {
1940
1890
  }
1941
1891
  }, t("retry")) : null
1942
1892
  ]) : null,
1893
+ // 版本 / 自动更新提示:已下载→提示重启;发现新版→一键更新
1894
+ update.data && update.data.staged ? import_react.default.createElement("div", {
1895
+ key: "update",
1896
+ style: {
1897
+ display: "flex",
1898
+ alignItems: "center",
1899
+ gap: 8,
1900
+ flexWrap: "wrap",
1901
+ fontSize: 11.5,
1902
+ color: C.label2,
1903
+ padding: "6px 10px",
1904
+ borderRadius: 9,
1905
+ border: `1px solid color-mix(in srgb, ${GOLD_TINT} 45%, transparent)`,
1906
+ background: `color-mix(in srgb, ${GOLD_TINT} 10%, transparent)`
1907
+ }
1908
+ }, [
1909
+ import_react.default.createElement("span", { key: "dot", "aria-hidden": "true", style: { width: 6, height: 6, borderRadius: "50%", background: GOLD_TINT } }),
1910
+ import_react.default.createElement(
1911
+ "span",
1912
+ { key: "txt", style: { flex: "1 1 160px" } },
1913
+ t("updateStaged").replace("{version}", String(update.data.stagedVersion || update.data.latestVersion || ""))
1914
+ )
1915
+ ]) : update.status === "updating" ? import_react.default.createElement("div", {
1916
+ key: "update",
1917
+ style: { fontSize: 11.5, color: C.label3, padding: "2px 2px" }
1918
+ }, t("updating")) : update.data && update.data.hasUpdate ? import_react.default.createElement("div", {
1919
+ key: "update",
1920
+ style: {
1921
+ display: "flex",
1922
+ alignItems: "center",
1923
+ gap: 8,
1924
+ flexWrap: "wrap",
1925
+ fontSize: 11.5,
1926
+ color: C.label2,
1927
+ padding: "6px 10px",
1928
+ borderRadius: 9,
1929
+ border: `1px solid color-mix(in srgb, ${GOLD_TINT} 45%, transparent)`,
1930
+ background: `color-mix(in srgb, ${GOLD_TINT} 10%, transparent)`
1931
+ }
1932
+ }, [
1933
+ import_react.default.createElement(
1934
+ "span",
1935
+ { key: "txt", style: { flex: "1 1 160px" } },
1936
+ t("updateAvailable").replace("{version}", String(update.data.latestVersion || ""))
1937
+ ),
1938
+ import_react.default.createElement("button", {
1939
+ key: "go",
1940
+ type: "button",
1941
+ onClick: update.download,
1942
+ style: {
1943
+ border: `1px solid color-mix(in srgb, ${GOLD_TINT} 70%, rgba(0,0,0,.15))`,
1944
+ color: "#3A2A05",
1945
+ background: "linear-gradient(180deg, #F8DC86 0%, #EFC258 52%, #D99D2C 100%)",
1946
+ borderRadius: 7,
1947
+ padding: "3px 12px",
1948
+ fontSize: 11.5,
1949
+ fontWeight: 700,
1950
+ cursor: "pointer"
1951
+ }
1952
+ }, t("updateNow"))
1953
+ ]) : null,
1943
1954
  import_react.default.createElement(HeroCard, { key: "hero", t, snap, decimals: cfg.decimals, onDecimalsChange, turbo: cfg.turbo, onTurboChange }),
1944
1955
  import_react.default.createElement(CountdownRow, { key: "countdown", t, snap }),
1945
1956
  import_react.default.createElement("div", {
@@ -2004,7 +2015,28 @@ function NiumaPanel(props) {
2004
2015
  setCheck,
2005
2016
  save,
2006
2017
  onSave
2007
- })
2018
+ }),
2019
+ // 版本信息固定放在面板最底部
2020
+ import_react.default.createElement("div", {
2021
+ key: "version",
2022
+ style: {
2023
+ display: "flex",
2024
+ alignItems: "center",
2025
+ justifyContent: "center",
2026
+ gap: 6,
2027
+ padding: "4px 0 0",
2028
+ fontSize: 10.5,
2029
+ color: C.label3,
2030
+ ...NUM
2031
+ }
2032
+ }, [
2033
+ versionText ? import_react.default.createElement("span", { key: "v" }, `\u725B\u9A6C\u52A0\u901F\u5668 v${versionText}`) : null,
2034
+ update.data && update.data.staged ? import_react.default.createElement(
2035
+ "span",
2036
+ { key: "s", style: { color: C.label3 } },
2037
+ `\xB7 ${t("updateStaged").replace("{version}", String(update.data.stagedVersion || update.data.latestVersion || ""))}`
2038
+ ) : null
2039
+ ])
2008
2040
  ])
2009
2041
  ]);
2010
2042
  }