clocktopus 1.7.0 → 1.8.1

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.
@@ -3,11 +3,15 @@ import { google } from 'googleapis';
3
3
  import { getAuthenticatedClient, getRefreshedToken } from '../../lib/google.js';
4
4
  import { getLatestToken, storeToken, getEventProject, setEventProject, getActiveProjects } from '../../lib/db.js';
5
5
  import { Clockify } from '../../clockify.js';
6
+ import { isClockifyEnabled } from '../../lib/credentials.js';
6
7
  // Hardcoded — registered with Google OAuth; cannot vary with CLOCKTOPUS_PORT
7
8
  // without re-registering the redirect URI in the Google Cloud console.
8
9
  const DASHBOARD_REDIRECT_URI = 'http://localhost:4001/api/google/callback';
9
10
  const calendarRoutes = new Hono();
10
11
  calendarRoutes.get('/calendar/events', async (c) => {
12
+ if (!isClockifyEnabled()) {
13
+ return c.json({ ok: false, error: 'Calendar sync requires Clockify.' }, 400);
14
+ }
11
15
  const start = c.req.query('start');
12
16
  const end = c.req.query('end');
13
17
  if (!start || !end) {
@@ -88,6 +92,9 @@ calendarRoutes.get('/calendar/events', async (c) => {
88
92
  }
89
93
  });
90
94
  calendarRoutes.post('/calendar/log', async (c) => {
95
+ if (!isClockifyEnabled()) {
96
+ return c.json({ ok: false, error: 'Calendar sync requires Clockify.' }, 400);
97
+ }
91
98
  const { entries } = await c.req.json();
92
99
  if (!entries || !Array.isArray(entries) || entries.length === 0) {
93
100
  return c.json({ ok: false, error: 'Entries array is required.' }, 400);
@@ -1,7 +1,12 @@
1
1
  import { Hono } from 'hono';
2
2
  import axios from 'axios';
3
- import { saveCredential } from '../../lib/credentials.js';
3
+ import { saveCredential, setClockifyDisabled } from '../../lib/credentials.js';
4
4
  const clockifyRoutes = new Hono();
5
+ clockifyRoutes.post('/clockify/enabled', async (c) => {
6
+ const { enabled } = await c.req.json();
7
+ setClockifyDisabled(!enabled);
8
+ return c.json({ ok: true });
9
+ });
5
10
  clockifyRoutes.post('/clockify', async (c) => {
6
11
  const { apiKey } = await c.req.json();
7
12
  if (!apiKey) {
@@ -1,6 +1,8 @@
1
1
  import { Hono } from 'hono';
2
2
  import { getRecentSessions, getSessionCount, getActiveProjects, getAllProjects, upsertProjects, toggleProjectActive, } from '../../lib/db.js';
3
3
  import { Clockify } from '../../clockify.js';
4
+ import { isClockifyEnabled, isJiraDisabled } from '../../lib/credentials.js';
5
+ import { getJiraTicket } from '../../lib/jira.js';
4
6
  const dataRoutes = new Hono();
5
7
  // Active projects for timer dropdown
6
8
  dataRoutes.get('/projects', (c) => {
@@ -14,6 +16,9 @@ dataRoutes.get('/projects/all', (c) => {
14
16
  });
15
17
  // Fetch projects from Clockify and save to DB
16
18
  dataRoutes.post('/projects/fetch', async (c) => {
19
+ if (!isClockifyEnabled()) {
20
+ return c.json({ ok: false, error: 'Clockify not configured.' }, 400);
21
+ }
17
22
  try {
18
23
  const clockify = new Clockify();
19
24
  const user = await clockify.getUser();
@@ -48,7 +53,7 @@ dataRoutes.get('/sessions', (c) => {
48
53
  const projectMap = new Map(allProjects.map((p) => [p.id, p.name]));
49
54
  const enriched = sessions.map((s) => ({
50
55
  ...s,
51
- projectName: projectMap.get(s.projectId) ?? 'Unknown',
56
+ projectName: s.projectId ? (projectMap.get(s.projectId) ?? 'Unknown') : null,
52
57
  }));
53
58
  return c.json({
54
59
  data: enriched,
@@ -58,4 +63,23 @@ dataRoutes.get('/sessions', (c) => {
58
63
  totalPages: Math.ceil(total / limit),
59
64
  });
60
65
  });
66
+ // Current Jira ticket summary for timer description preview
67
+ dataRoutes.get('/jira/ticket-summary', async (c) => {
68
+ const ticket = (c.req.query('jira') || '').trim().toUpperCase();
69
+ if (!ticket || !/^[A-Z][A-Z0-9]+-\d+$/.test(ticket)) {
70
+ return c.json({ ok: false, error: 'Invalid ticket.' }, 400);
71
+ }
72
+ if (isJiraDisabled())
73
+ return c.json({ ok: true, description: null });
74
+ try {
75
+ const issue = (await getJiraTicket(ticket));
76
+ const summary = issue?.fields?.summary?.trim();
77
+ if (summary)
78
+ return c.json({ ok: true, description: summary });
79
+ }
80
+ catch (err) {
81
+ console.warn('Jira summary lookup failed for', ticket, err);
82
+ }
83
+ return c.json({ ok: true, description: null });
84
+ });
61
85
  export default dataRoutes;
@@ -1,9 +1,14 @@
1
1
  import { Hono } from 'hono';
2
2
  import axios from 'axios';
3
- import { saveCredential } from '../../lib/credentials.js';
4
- import { storeAtlassianToken } from '../../lib/db.js';
3
+ import { saveCredential, setJiraDisabled } from '../../lib/credentials.js';
4
+ import { clearAtlassianToken, storeAtlassianToken } from '../../lib/db.js';
5
5
  import { getAtlassianAuthUrl, exchangeCodeForTokens, getAccessibleResources } from '../../lib/atlassian.js';
6
6
  const jiraRoutes = new Hono();
7
+ jiraRoutes.post('/jira/enabled', async (c) => {
8
+ const { enabled } = await c.req.json();
9
+ setJiraDisabled(!enabled);
10
+ return c.json({ ok: true });
11
+ });
7
12
  // OAuth: redirect to Atlassian authorization (browser fallback)
8
13
  jiraRoutes.get('/jira/connect', async (c) => {
9
14
  try {
@@ -70,6 +75,7 @@ jiraRoutes.post('/jira', async (c) => {
70
75
  saveCredential('ATLASSIAN_URL', url);
71
76
  saveCredential('ATLASSIAN_EMAIL', email);
72
77
  saveCredential('ATLASSIAN_API_TOKEN', token);
78
+ clearAtlassianToken();
73
79
  return c.json({ ok: true, user: res.data.displayName });
74
80
  }
75
81
  return c.json({ ok: false, error: 'Invalid credentials.' });
@@ -1,28 +1,33 @@
1
1
  import { Hono } from 'hono';
2
2
  import axios from 'axios';
3
- import { resolveCredential } from '../../lib/credentials.js';
3
+ import { isClockifyDisabled, isJiraDisabled, resolveCredential } from '../../lib/credentials.js';
4
4
  import { getLatestToken, getAtlassianToken } from '../../lib/db.js';
5
5
  import { getValidAccessToken } from '../../lib/atlassian.js';
6
6
  const statusRoutes = new Hono();
7
7
  statusRoutes.get('/status', async (c) => {
8
8
  const results = {
9
9
  clockify: false,
10
+ clockifyDisabled: isClockifyDisabled(),
10
11
  google: false,
11
12
  jira: false,
13
+ jiraDisabled: isJiraDisabled(),
14
+ jiraConfigured: false,
12
15
  jiraOAuth: false,
13
16
  };
14
17
  // Check Clockify
15
18
  const clockifyKey = resolveCredential('CLOCKIFY_API_KEY');
16
19
  if (clockifyKey) {
17
20
  results.clockifyKeyHint = '***' + clockifyKey.slice(-4);
18
- try {
19
- const res = await axios.get('https://api.clockify.me/api/v1/user', {
20
- headers: { 'X-Api-Key': clockifyKey },
21
- timeout: 5000,
22
- });
23
- results.clockify = res.status === 200;
21
+ if (!results.clockifyDisabled) {
22
+ try {
23
+ const res = await axios.get('https://api.clockify.me/api/v1/user', {
24
+ headers: { 'X-Api-Key': clockifyKey },
25
+ timeout: 5000,
26
+ });
27
+ results.clockify = res.status === 200;
28
+ }
29
+ catch { }
24
30
  }
25
- catch { }
26
31
  }
27
32
  // Check Google — token exists in DB
28
33
  const token = getLatestToken();
@@ -36,29 +41,32 @@ statusRoutes.get('/status', async (c) => {
36
41
  const storedAtlassianToken = getAtlassianToken();
37
42
  if (storedAtlassianToken) {
38
43
  results.jiraOAuth = true;
44
+ results.jiraConfigured = true;
39
45
  if (storedAtlassianToken.site_url)
40
46
  results.jiraSiteUrl = storedAtlassianToken.site_url;
41
- try {
42
- const validToken = await getValidAccessToken();
43
- if (validToken) {
44
- const res = await axios.get(`https://api.atlassian.com/ex/jira/${validToken.cloud_id}/rest/api/3/myself`, {
45
- headers: {
46
- Authorization: `Bearer ${validToken.access_token}`,
47
- Accept: 'application/json',
48
- },
49
- timeout: 5000,
50
- });
51
- results.jira = res.status === 200;
52
- }
53
- }
54
- catch (error) {
55
- if (axios.isAxiosError(error)) {
56
- console.error('Jira OAuth status check failed:', error.response?.status, error.response?.data);
47
+ if (!results.jiraDisabled) {
48
+ try {
49
+ const validToken = await getValidAccessToken();
50
+ if (validToken) {
51
+ const res = await axios.get(`https://api.atlassian.com/ex/jira/${validToken.cloud_id}/rest/api/3/myself`, {
52
+ headers: {
53
+ Authorization: `Bearer ${validToken.access_token}`,
54
+ Accept: 'application/json',
55
+ },
56
+ timeout: 5000,
57
+ });
58
+ results.jira = res.status === 200;
59
+ }
57
60
  }
58
- else {
59
- console.error('Jira OAuth status check failed:', error);
61
+ catch (error) {
62
+ if (axios.isAxiosError(error)) {
63
+ console.error('Jira OAuth status check failed:', error.response?.status, error.response?.data);
64
+ }
65
+ else {
66
+ console.error('Jira OAuth status check failed:', error);
67
+ }
68
+ results.jira = false;
60
69
  }
61
- results.jira = false;
62
70
  }
63
71
  }
64
72
  else {
@@ -66,17 +74,20 @@ statusRoutes.get('/status', async (c) => {
66
74
  const jiraToken = resolveCredential('ATLASSIAN_API_TOKEN');
67
75
  const jiraEmail = resolveCredential('ATLASSIAN_EMAIL');
68
76
  if (jiraUrl && jiraToken && jiraEmail) {
69
- try {
70
- const res = await axios.get(`${jiraUrl}/myself`, {
71
- headers: {
72
- Authorization: `Basic ${Buffer.from(`${jiraEmail}:${jiraToken}`).toString('base64')}`,
73
- Accept: 'application/json',
74
- },
75
- timeout: 5000,
76
- });
77
- results.jira = res.status === 200;
77
+ results.jiraConfigured = true;
78
+ if (!results.jiraDisabled) {
79
+ try {
80
+ const res = await axios.get(`${jiraUrl}/myself`, {
81
+ headers: {
82
+ Authorization: `Basic ${Buffer.from(`${jiraEmail}:${jiraToken}`).toString('base64')}`,
83
+ Accept: 'application/json',
84
+ },
85
+ timeout: 5000,
86
+ });
87
+ results.jira = res.status === 200;
88
+ }
89
+ catch { }
78
90
  }
79
- catch { }
80
91
  }
81
92
  }
82
93
  return c.json(results);
@@ -2,26 +2,54 @@ import { Hono } from 'hono';
2
2
  import { v4 as uuidv4 } from 'uuid';
3
3
  import { Clockify } from '../../clockify.js';
4
4
  import { completeLatestSession, deleteSessionById, getOpenSession, getSessionById, logCompletedSession, logSessionStart, setSessionJiraWorklogId, } from '../../lib/db.js';
5
- import { deleteJiraWorklog, stopJiraTimer } from '../../lib/jira.js';
5
+ import { deleteJiraWorklog, getJiraTicket, stopJiraTimer } from '../../lib/jira.js';
6
+ import { isClockifyEnabled, isJiraDisabled } from '../../lib/credentials.js';
6
7
  function extractJiraTicket(description) {
7
8
  const match = description.match(/\b([A-Z][A-Z0-9]+-\d+)\b/);
8
9
  return match?.[1];
9
10
  }
11
+ async function buildJiraDescription(ticket, typed) {
12
+ if (typed && typed !== ticket)
13
+ return typed;
14
+ if (isJiraDisabled())
15
+ return ticket;
16
+ try {
17
+ const issue = (await getJiraTicket(ticket));
18
+ const summary = issue?.fields?.summary?.trim();
19
+ if (summary)
20
+ return ticket + ' ' + summary;
21
+ }
22
+ catch (err) {
23
+ console.warn('Jira summary lookup failed for', ticket, err);
24
+ }
25
+ return ticket;
26
+ }
10
27
  const timerRoutes = new Hono();
11
28
  timerRoutes.get('/timer/active', async (c) => {
12
29
  try {
30
+ if (!isClockifyEnabled()) {
31
+ const openSession = getOpenSession();
32
+ if (!openSession)
33
+ return c.json({ active: false });
34
+ return c.json({
35
+ active: true,
36
+ description: openSession.description,
37
+ projectId: openSession.projectId,
38
+ start: openSession.startedAt,
39
+ ...(openSession.jiraTicket ? { jiraTicket: openSession.jiraTicket } : {}),
40
+ });
41
+ }
13
42
  const clockify = new Clockify();
14
43
  const user = await clockify.getUser();
15
44
  if (!user)
16
45
  return c.json({ active: false });
17
46
  const timer = await clockify.getActiveTimer(user.defaultWorkspace, user.id);
18
47
  if (!timer) {
19
- // Timer stopped externally (e.g. in Clockify app) — close any lingering open session
20
48
  const openSession = getOpenSession();
21
49
  if (openSession) {
22
50
  const completedAt = new Date().toISOString();
23
51
  completeLatestSession(completedAt, false);
24
- if (openSession.jiraTicket) {
52
+ if (openSession.jiraTicket && !isJiraDisabled()) {
25
53
  const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(openSession.startedAt).getTime()) / 1000);
26
54
  if (timeSpentSeconds >= 60) {
27
55
  try {
@@ -37,7 +65,6 @@ timerRoutes.get('/timer/active', async (c) => {
37
65
  }
38
66
  return c.json({ active: false });
39
67
  }
40
- // Sync externally-started timers (e.g. from Clockify app or Jira plugin) to DB
41
68
  const timerStart = timer.timeInterval.start;
42
69
  const jiraTicket = extractJiraTicket(timer.description ?? '');
43
70
  const openSession = getOpenSession();
@@ -59,36 +86,78 @@ timerRoutes.get('/timer/active', async (c) => {
59
86
  });
60
87
  timerRoutes.post('/timer/start', async (c) => {
61
88
  const { projectId, description, jiraTicket, billable } = await c.req.json();
62
- if (!projectId || !description) {
63
- return c.json({ ok: false, error: 'Project and description are required.' }, 400);
89
+ const cleanDescription = (description ?? '').trim();
90
+ const cleanJira = jiraTicket?.trim() || undefined;
91
+ const clockifyOn = isClockifyEnabled();
92
+ if (clockifyOn) {
93
+ if (!projectId || !cleanDescription) {
94
+ return c.json({ ok: false, error: 'Project and description are required.' }, 400);
95
+ }
96
+ let clockifyStarted = false;
97
+ try {
98
+ const clockify = new Clockify();
99
+ const user = await clockify.getUser();
100
+ if (user) {
101
+ const result = await clockify.startTimer(user.defaultWorkspace, projectId, cleanDescription, cleanJira, billable ?? true);
102
+ if (result)
103
+ clockifyStarted = true;
104
+ else
105
+ console.warn('Clockify startTimer returned null; falling through to Jira-only path.');
106
+ }
107
+ else {
108
+ console.warn('Clockify enabled but getUser failed; falling through to Jira-only path.');
109
+ }
110
+ }
111
+ catch (err) {
112
+ console.warn('Clockify start threw; falling through to Jira-only path:', err);
113
+ }
114
+ if (clockifyStarted)
115
+ return c.json({ ok: true });
64
116
  }
117
+ // Jira-only or local-only path (also used as fallback when Clockify is unreachable)
118
+ if (!cleanJira && !cleanDescription) {
119
+ return c.json({ ok: false, error: 'Description or Jira ticket is required.' }, 400);
120
+ }
121
+ const finalDescription = cleanJira ? await buildJiraDescription(cleanJira, cleanDescription) : cleanDescription;
122
+ const sessionId = uuidv4();
123
+ const startedAt = new Date().toISOString();
65
124
  try {
66
- const clockify = new Clockify();
67
- const user = await clockify.getUser();
68
- if (!user)
69
- return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
70
- const result = await clockify.startTimer(user.defaultWorkspace, projectId, description, jiraTicket, billable ?? true);
71
- if (!result)
72
- return c.json({ ok: false, error: 'Failed to start timer.' }, 500);
125
+ logSessionStart(sessionId, projectId ?? null, finalDescription, startedAt, cleanJira);
73
126
  return c.json({ ok: true });
74
127
  }
75
- catch {
128
+ catch (err) {
129
+ console.error('Error starting session:', err);
76
130
  return c.json({ ok: false, error: 'Failed to start timer.' }, 500);
77
131
  }
78
132
  });
79
133
  timerRoutes.post('/timer/stop', async (c) => {
80
134
  try {
81
- const clockify = new Clockify();
82
- const user = await clockify.getUser();
83
- if (!user)
84
- return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
85
135
  const openSession = getOpenSession();
86
- const result = await clockify.stopTimer(user.defaultWorkspace, user.id);
87
- if (!result)
88
- return c.json({ ok: false, error: 'Failed to stop timer.' }, 500);
136
+ if (isClockifyEnabled()) {
137
+ try {
138
+ const clockify = new Clockify();
139
+ const user = await clockify.getUser();
140
+ if (user) {
141
+ const result = await clockify.stopTimer(user.defaultWorkspace, user.id);
142
+ if (!result)
143
+ console.warn('Clockify stopTimer returned null; proceeding with DB + worklog.');
144
+ }
145
+ else {
146
+ console.warn('Clockify enabled but getUser failed; proceeding with DB + worklog.');
147
+ }
148
+ }
149
+ catch (err) {
150
+ console.warn('Clockify stop threw; proceeding with DB + worklog:', err);
151
+ }
152
+ if (!openSession)
153
+ return c.json({ ok: false, error: 'No active timer.' }, 404);
154
+ }
155
+ else if (!openSession) {
156
+ return c.json({ ok: false, error: 'No active timer.' }, 404);
157
+ }
89
158
  const completedAt = new Date().toISOString();
90
159
  completeLatestSession(completedAt, false);
91
- if (openSession?.jiraTicket) {
160
+ if (openSession?.jiraTicket && !isJiraDisabled()) {
92
161
  const timeSpentSeconds = Math.round((new Date(completedAt).getTime() - new Date(openSession.startedAt).getTime()) / 1000);
93
162
  if (timeSpentSeconds >= 60) {
94
163
  try {
@@ -109,9 +178,6 @@ timerRoutes.post('/timer/stop', async (c) => {
109
178
  });
110
179
  timerRoutes.post('/timer/log', async (c) => {
111
180
  const { projectId, description, start, end, jiraTicket, billable } = await c.req.json();
112
- if (!projectId) {
113
- return c.json({ ok: false, error: 'Project is required.' }, 400);
114
- }
115
181
  if (!start || !end) {
116
182
  return c.json({ ok: false, error: 'Start and end are required.' }, 400);
117
183
  }
@@ -125,23 +191,51 @@ timerRoutes.post('/timer/log', async (c) => {
125
191
  }
126
192
  const cleanDescription = (description ?? '').trim();
127
193
  const cleanJira = jiraTicket?.trim() || undefined;
194
+ const clockifyOn = isClockifyEnabled();
195
+ if (clockifyOn && !projectId) {
196
+ return c.json({ ok: false, error: 'Project is required.' }, 400);
197
+ }
128
198
  if (!cleanDescription && !cleanJira) {
129
199
  return c.json({ ok: false, error: 'Description or Jira ticket is required.' }, 400);
130
200
  }
201
+ const startIso = new Date(startMs).toISOString();
202
+ const endIso = new Date(endMs).toISOString();
203
+ const clockifyDescription = cleanDescription || cleanJira || '';
204
+ let entryId;
205
+ let clockifySucceeded = false;
131
206
  try {
132
- const clockify = new Clockify();
133
- const user = await clockify.getUser();
134
- if (!user)
135
- return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
136
- const startIso = new Date(startMs).toISOString();
137
- const endIso = new Date(endMs).toISOString();
138
- const finalDescription = cleanDescription || cleanJira;
139
- const entry = await clockify.logTime(user.defaultWorkspace, projectId, startIso, endIso, finalDescription, billable ?? true);
140
- if (!entry)
141
- return c.json({ ok: false, error: 'Failed to log time in Clockify.' }, 500);
142
- const entryId = entry.id ?? uuidv4();
143
- logCompletedSession(entryId, projectId, finalDescription, startIso, endIso, cleanJira);
144
- if (cleanJira) {
207
+ if (clockifyOn) {
208
+ try {
209
+ const clockify = new Clockify();
210
+ const user = await clockify.getUser();
211
+ if (user) {
212
+ const entry = await clockify.logTime(user.defaultWorkspace, projectId, startIso, endIso, clockifyDescription, billable ?? true);
213
+ if (entry) {
214
+ entryId = entry.id ?? uuidv4();
215
+ clockifySucceeded = true;
216
+ }
217
+ else {
218
+ console.warn('Clockify logTime returned null; falling through to Jira-only path.');
219
+ }
220
+ }
221
+ else {
222
+ console.warn('Clockify enabled but getUser failed; falling through to Jira-only path.');
223
+ }
224
+ }
225
+ catch (err) {
226
+ console.warn('Clockify log threw; falling through to Jira-only path:', err);
227
+ }
228
+ }
229
+ if (!entryId) {
230
+ entryId = uuidv4();
231
+ }
232
+ const finalDescription = clockifySucceeded
233
+ ? clockifyDescription
234
+ : cleanJira
235
+ ? await buildJiraDescription(cleanJira, cleanDescription)
236
+ : cleanDescription;
237
+ logCompletedSession(entryId, projectId ?? null, finalDescription, startIso, endIso, cleanJira);
238
+ if (cleanJira && !isJiraDisabled()) {
145
239
  const timeSpentSeconds = Math.round((endMs - startMs) / 1000);
146
240
  if (timeSpentSeconds >= 60) {
147
241
  try {
@@ -169,15 +263,19 @@ timerRoutes.delete('/timer/:id', async (c) => {
169
263
  if (!session)
170
264
  return c.json({ ok: false, error: 'Session not found.' }, 404);
171
265
  try {
172
- const clockify = new Clockify();
173
- const user = await clockify.getUser();
174
- if (!user)
175
- return c.json({ ok: false, error: 'Could not connect to Clockify.' }, 500);
176
- const clockifyOk = await clockify.deleteTimeEntry(user.defaultWorkspace, id);
177
- // Continue even if Clockify delete fails — the entry may already be gone remotely.
178
- if (!clockifyOk)
179
- console.warn(`Clockify delete returned failure for ${id}; removing local record anyway.`);
180
- if (session.jiraTicket && session.jiraWorklogId) {
266
+ if (isClockifyEnabled()) {
267
+ const clockify = new Clockify();
268
+ const user = await clockify.getUser();
269
+ if (user) {
270
+ const clockifyOk = await clockify.deleteTimeEntry(user.defaultWorkspace, id);
271
+ if (!clockifyOk)
272
+ console.warn(`Clockify delete returned failure for ${id}; removing local record anyway.`);
273
+ }
274
+ else {
275
+ console.warn('Clockify enabled but getUser failed; skipping remote delete.');
276
+ }
277
+ }
278
+ if (session.jiraTicket && session.jiraWorklogId && !isJiraDisabled()) {
181
279
  try {
182
280
  await deleteJiraWorklog(session.jiraTicket, session.jiraWorklogId);
183
281
  }