1 | package client |
||
2 | |||
3 | import ( |
||
4 | "fmt" |
||
5 | "github.com/gen2brain/beeep" |
||
6 | "github.com/popstas/planfix-go/planfix" |
||
7 | "github.com/viasite/planfix-toggl-server/app/config" |
||
8 | "github.com/viasite/planfix-toggl-server/app/util" |
||
9 | "io/ioutil" |
||
10 | "log" |
||
11 | "math" |
||
12 | "net/smtp" |
||
13 | "reflect" |
||
14 | "regexp" |
||
15 | "sort" |
||
16 | "strconv" |
||
17 | "strings" |
||
18 | "time" |
||
19 | ) |
||
20 | |||
21 | type TogglSession interface { |
||
22 | GetAccount() (toggl.Account, error) |
||
23 | AddRemoveTag(entryID int, tag string, add bool) (toggl.TimeEntry, error) |
||
24 | GetCurrentTimeEntry() (toggl.TimeEntry, error) |
||
25 | GetSummaryReport(workspace int, since, until string) (toggl.SummaryReport, error) |
||
26 | GetDetailedReport(workspace int, since, until string, page int) (toggl.DetailedReport, error) |
||
27 | GetDetailedReportV2(rp toggl.DetailedReportParams) (toggl.DetailedReport, error) |
||
28 | GetTagByName(name string, wid int) (tag toggl.Tag, err error) |
||
29 | GetWorkspaces() (workspaces []toggl.Workspace, err error) |
||
30 | } |
||
31 | |||
32 | // TogglClient - Клиент, общающийся с Toggl и Планфиксом |
||
33 | type TogglClient struct { |
||
34 | Session TogglSession |
||
35 | Config *config.Config |
||
36 | PlanfixAPI planfix.API |
||
37 | Logger *log.Logger |
||
38 | analiticData PlanfixAnaliticData |
||
39 | SentLog map[string]int |
||
40 | Opts map[string]string |
||
41 | } |
||
42 | |||
43 | // PlanfixEntryData - Данные, подмешивающиеся к toggl.DetailedTimeEntry |
||
44 | type PlanfixEntryData struct { |
||
45 | Sent bool `json:"sent"` |
||
46 | TaskID int `json:"task_id"` |
||
47 | GroupCount int `json:"group_count"` |
||
48 | } |
||
49 | |||
50 | // TogglPlanfixEntry - toggl.DetailedTimeEntry дополнительными данными о задаче в Планфиксе |
||
51 | type TogglPlanfixEntry struct { |
||
52 | toggl.DetailedTimeEntry |
||
53 | Planfix PlanfixEntryData `json:"planfix"` |
||
54 | } |
||
55 | |||
56 | // TogglPlanfixEntryGroup - группа toggl-записей, объединенных одной задачей Планфикса |
||
57 | type TogglPlanfixEntryGroup struct { |
||
58 | Entries []TogglPlanfixEntry |
||
59 | Description string |
||
60 | Project string |
||
61 | ProjectHexColor string |
||
62 | Duration int64 |
||
63 | } |
||
64 | |||
65 | // PlanfixAnaliticData - данные аналитики, которая будет проставляться в Планфикс |
||
66 | type PlanfixAnaliticData struct { |
||
67 | ID int |
||
68 | TypeID int |
||
69 | TypeValueID int |
||
70 | CountID int |
||
71 | CommentID int |
||
72 | DateID int |
||
73 | UsersID int |
||
74 | } |
||
75 | |||
76 | // Run запускает фоновые процессы |
||
77 | func (c TogglClient) Run() { |
||
78 | // init map |
||
79 | // start tag cleaner |
||
80 | go c.RunTagCleaner() |
||
81 | // start sender |
||
82 | go c.RunSender() |
||
83 | } |
||
84 | |||
85 | // RunSender - запускалка цикла отправки накопившихся toggl-записей |
||
86 | func (c TogglClient) RunSender() { |
||
87 | if c.Config.SendInterval <= 0 { |
||
88 | c.Logger.Println("[INFO] Интервал отправки не установлен, периодическая отправка отключена") |
||
89 | return |
||
90 | } |
||
91 | |||
92 | time.Sleep(1 * time.Second) // wait for server start |
||
93 | for { |
||
94 | c.SendToPlanfix() |
||
95 | time.Sleep(time.Duration(c.Config.SendInterval) * time.Minute) |
||
96 | } |
||
97 | } |
||
98 | |||
99 | // ReloadConfig - пересоздает API из последней версии конфига |
||
100 | func (c *TogglClient) ReloadConfig() { |
||
101 | c.PlanfixAPI = planfix.New( |
||
102 | c.Config.PlanfixAPIUrl, |
||
103 | c.Config.PlanfixAPIKey, |
||
104 | c.Config.PlanfixAccount, |
||
105 | c.Config.PlanfixUserName, |
||
106 | c.Config.PlanfixUserPassword, |
||
107 | ) |
||
108 | if !c.Config.Debug { |
||
109 | c.PlanfixAPI.Logger.SetFlags(0) |
||
110 | c.PlanfixAPI.Logger.SetOutput(ioutil.Discard) |
||
111 | } |
||
112 | c.PlanfixAPI.UserAgent = "planfix-toggl" |
||
113 | |||
114 | sess := toggl.OpenSession(c.Config.TogglAPIToken) |
||
115 | c.Session = &sess |
||
116 | } |
||
117 | |||
118 | // RunTagCleaner - запускалка цикла очистки запущенных toggl-записей от тега sent |
||
119 | func (c TogglClient) RunTagCleaner() { |
||
120 | time.Sleep(1 * time.Second) // wait for server start |
||
121 | for { |
||
122 | entry, err := c.Session.GetCurrentTimeEntry() |
||
123 | if err != nil { |
||
124 | c.Logger.Println("[ERROR] не удалось получить текущую toggl-запись") |
||
125 | continue |
||
126 | } |
||
127 | |||
128 | // delete sent tag |
||
129 | for _, tag := range entry.Tags { |
||
130 | if tag == c.Config.TogglSentTag { |
||
131 | c.Logger.Printf("[INFO] убран тег %s из текущей записи %s", c.Config.TogglSentTag, entry.Description) |
||
132 | c.Session.AddRemoveTag(entry.ID, c.Config.TogglSentTag, false) |
||
133 | } |
||
134 | } |
||
135 | |||
136 | time.Sleep(1 * time.Minute) |
||
137 | } |
||
138 | } |
||
139 | |||
140 | // SendToPlanfix получает записи из Toggl и отправляет в Планфикс |
||
141 | // * нужна, чтобы сохранился c.PlanfixAPI.Sid при авторизации |
||
142 | func (c *TogglClient) SendToPlanfix() (err error) { |
||
143 | c.Logger.Println("[INFO] отправка в Планфикс") |
||
144 | pendingEntries, err := c.GetPendingEntries() |
||
145 | if err != nil { |
||
146 | return err |
||
147 | } |
||
148 | c.Logger.Printf("[INFO] в очереди на отправку: %d", len(pendingEntries)) |
||
149 | days := c.GroupEntriesByDay(pendingEntries) |
||
150 | for day, dayEntries := range days { |
||
151 | tasks := c.GroupEntriesByTask(dayEntries) |
||
152 | minsTotal := 0 |
||
153 | |||
154 | for taskID, entries := range tasks { |
||
155 | err, mins := c.sendEntries(taskID, entries) |
||
156 | |||
157 | minsTotal += mins |
||
158 | // add to day time |
||
159 | if dayMins, ok := c.SentLog[day]; ok { |
||
160 | c.SentLog[day] = dayMins + mins |
||
161 | } else { |
||
162 | c.SentLog[day] = mins |
||
163 | } |
||
164 | |||
165 | taskURL := fmt.Sprintf("https://%s.planfix.ru/task/%d", c.Config.PlanfixAccount, taskID) |
||
166 | if err != nil { |
||
167 | c.Logger.Printf("[ERROR] записи к задаче %s (%s) не удалось отправить: %s", taskURL, day, err) |
||
168 | } else { |
||
169 | c.Logger.Printf("[INFO] %d минут отправлены на %s (%s)", mins, taskURL, day) |
||
170 | } |
||
171 | } |
||
172 | dayHours := int(c.SentLog[day] / 60) |
||
173 | dayMins := c.SentLog[day] % 60 |
||
174 | dayTime := fmt.Sprintf("%dч%02dм", dayHours, dayMins) |
||
175 | dayString := strings.ReplaceAll(day, "-", ".") |
||
176 | |||
177 | msg := fmt.Sprintf("%s, %s, всего %s за %s", util.PluralMins(minsTotal), util.PluralTasks(len(tasks)), dayTime, dayString) |
||
178 | if c.Config.DryRun { |
||
179 | msg += " (тестовый режим)" |
||
180 | } |
||
181 | c.Logger.Printf("[INFO] %s", msg) |
||
182 | util.Notify(msg) |
||
183 | } |
||
184 | c.Opts["LastSent"] = time.Now().Format("15:04:05") |
||
185 | return nil |
||
186 | } |
||
187 | |||
188 | // GroupEntriesByDay объединяет плоский список toggl-записей в map c ключом - Y-m-d |
||
189 | func (c TogglClient) GroupEntriesByDay(entries []TogglPlanfixEntry) (grouped map[string][]TogglPlanfixEntry) { |
||
190 | grouped = make(map[string][]TogglPlanfixEntry) |
||
191 | if len(entries) == 0 { |
||
192 | return grouped |
||
193 | } |
||
194 | for _, entry := range entries { |
||
195 | day := entry.Start.Format("02-01-2006") |
||
196 | grouped[day] = append(grouped[day], entry) |
||
197 | } |
||
198 | return grouped |
||
199 | } |
||
200 | |||
201 | // GroupEntriesByTask объединяет плоский список toggl-записей в map c ключом - ID задачи в Планфиксе |
||
202 | func (c TogglClient) GroupEntriesByTask(entries []TogglPlanfixEntry) (grouped map[int][]TogglPlanfixEntry) { |
||
203 | grouped = make(map[int][]TogglPlanfixEntry) |
||
204 | if len(entries) == 0 { |
||
205 | return grouped |
||
206 | } |
||
207 | for _, entry := range entries { |
||
208 | grouped[entry.Planfix.TaskID] = append(grouped[entry.Planfix.TaskID], entry) |
||
209 | } |
||
210 | return grouped |
||
211 | } |
||
212 | |||
213 | // SumEntriesGroup объединяет несколько toggl-записей в одну с просуммированным временем |
||
214 | // Входной map формируется через GroupEntriesByTask |
||
215 | // Ключ массива - ID задачи в Планфиксе |
||
216 | func (c TogglClient) SumEntriesGroup(grouped map[int][]TogglPlanfixEntry) (summed []TogglPlanfixEntry) { |
||
217 | g := make(map[int]TogglPlanfixEntry) |
||
218 | for taskID, entries := range grouped { |
||
219 | for _, entry := range entries { |
||
220 | if ge, ok := g[taskID]; ok { |
||
221 | ge.Duration += entry.Duration |
||
222 | ge.Planfix.GroupCount++ |
||
223 | g[entry.Planfix.TaskID] = ge |
||
224 | } else { |
||
225 | entry.Description = c.getSumEntryName(entries) |
||
226 | g[entry.Planfix.TaskID] = entry |
||
227 | } |
||
228 | } |
||
229 | } |
||
230 | |||
231 | taskIDs := make([]int, 0, len(g)) |
||
232 | for k := range g { |
||
233 | taskIDs = append(taskIDs, k) |
||
234 | } |
||
235 | sort.Ints(taskIDs) |
||
236 | |||
237 | summed = make([]TogglPlanfixEntry, 0, len(g)) |
||
238 | for _, taskID := range taskIDs { |
||
239 | summed = append(summed, g[taskID]) |
||
240 | } |
||
241 | |||
242 | return summed |
||
243 | } |
||
244 | |||
245 | func (c TogglClient) getSumEntryName(entries []TogglPlanfixEntry) string { |
||
246 | names := []string{} |
||
247 | for _, entry := range entries { |
||
248 | names = append(names, entry.Description) |
||
249 | } |
||
250 | // sort |
||
251 | sort.Strings(names) |
||
252 | |||
253 | // group |
||
254 | groupNamesCounts := make(map[string]int) |
||
255 | for _, name := range names { |
||
256 | groupNamesCounts[name]++ |
||
257 | } |
||
258 | |||
259 | // keys |
||
260 | names = []string{} |
||
261 | for name, _ := range groupNamesCounts { |
||
262 | names = append(names, name) |
||
263 | } |
||
264 | |||
265 | return strings.Join(names, "\n") |
||
266 | } |
||
267 | |||
268 | // GetTogglUser возвращает юзера в Toggl |
||
269 | func (c TogglClient) GetTogglUser() (account toggl.Account, err error) { |
||
270 | account, err = c.Session.GetAccount() |
||
271 | if err != nil { |
||
272 | return account, fmt.Errorf("GetTogglUser: Не удалось получить Toggl UserID, проверьте TogglAPIToken, %s", err.Error()) |
||
273 | } |
||
274 | return account, nil |
||
275 | } |
||
276 | |||
277 | // IsWorkspaceExists проверяет наличие workspace в доступных |
||
278 | func (c TogglClient) IsWorkspaceExists(wid int) (bool, error) { |
||
279 | ws, err := c.Session.GetWorkspaces() |
||
280 | if err != nil { |
||
281 | return false, fmt.Errorf("Не удалось получить Toggl workspaces, проверьте TogglAPIToken, %s", err.Error()) |
||
282 | } |
||
283 | for _, w := range ws { |
||
284 | if w.ID == wid { |
||
285 | return true, nil |
||
286 | } |
||
287 | } |
||
288 | return false, nil |
||
289 | } |
||
290 | |||
291 | // GetPlanfixUser возвращает юзера в Планфиксе |
||
292 | func (c TogglClient) GetPlanfixUser() (user planfix.XMLResponseUser, err error) { |
||
293 | var userResponse planfix.XMLResponseUserGet |
||
294 | userResponse, err = c.PlanfixAPI.UserGet(0) |
||
295 | user = userResponse.User |
||
296 | if err != nil { |
||
297 | return user, fmt.Errorf("Не удалось получить Planfix UserID, проверьте PlanfixAPIKey, PlanfixAPIUrl, PlanfixUserName, PlanfixUserPassword, %s", err.Error()) |
||
298 | } |
||
299 | return user, nil |
||
300 | } |
||
301 | |||
302 | func (c TogglClient) ReportToTogglPlanfixEntry(report toggl.DetailedReport) (entries []TogglPlanfixEntry) { |
||
303 | for _, entry := range report.Data { |
||
304 | pfe := c.togglDetailedEntryToPlanfixTogglEntry(entry) |
||
305 | entries = append(entries, pfe) |
||
306 | } |
||
307 | return entries |
||
308 | } |
||
309 | |||
310 | func (c TogglClient) togglEntryToTogglDetailedEntry(entry toggl.TimeEntry) toggl.DetailedTimeEntry { |
||
311 | return toggl.DetailedTimeEntry{ |
||
312 | ID: entry.ID, |
||
313 | Pid: entry.Pid, |
||
314 | Tid: entry.Tid, |
||
315 | Description: entry.Description, |
||
316 | Start: entry.Start, |
||
317 | End: entry.Stop, |
||
318 | Tags: entry.Tags, |
||
319 | Duration: entry.Duration, |
||
320 | Billable: entry.Billable, |
||
321 | } |
||
322 | } |
||
323 | |||
324 | func (c TogglClient) togglDetailedEntryToPlanfixTogglEntry(entry toggl.DetailedTimeEntry) (pfe TogglPlanfixEntry) { |
||
325 | pfe = TogglPlanfixEntry{ |
||
326 | DetailedTimeEntry: entry, |
||
327 | Planfix: PlanfixEntryData{ |
||
328 | Sent: false, |
||
329 | TaskID: 0, |
||
330 | GroupCount: 1, |
||
331 | }, |
||
332 | } |
||
333 | |||
334 | for _, tag := range entry.Tags { |
||
335 | // only digit == planfix.task_id |
||
336 | regex := regexp.MustCompile(`^\d+$`) |
||
337 | if regex.MatchString(tag) { |
||
338 | pfe.Planfix.TaskID, _ = strconv.Atoi(tag) |
||
339 | } |
||
340 | |||
341 | // sent tag |
||
342 | if tag == c.Config.TogglSentTag { |
||
343 | pfe.Planfix.Sent = true |
||
344 | } |
||
345 | } |
||
346 | |||
347 | return pfe |
||
348 | } |
||
349 | |||
350 | // GetEntries получает []toggl.DetailedTimeEntry и превращает их в []TogglPlanfixEntry с подмешенными данными Планфикса |
||
351 | func (c TogglClient) GetCurrentEntry() (entry TogglPlanfixEntry, err error) { |
||
352 | togglEntry, err := c.Session.GetCurrentTimeEntry() |
||
353 | detailedEntry := c.togglEntryToTogglDetailedEntry(togglEntry) |
||
354 | entry = c.togglDetailedEntryToPlanfixTogglEntry(detailedEntry) |
||
355 | return entry, err |
||
356 | } |
||
357 | |||
358 | // GetEntries получает []toggl.DetailedTimeEntry и превращает их в []TogglPlanfixEntry с подмешенными данными Планфикса |
||
359 | func (c TogglClient) GetEntries(togglWorkspaceID int, since, until string) (entries []TogglPlanfixEntry, err error) { |
||
360 | report, err := c.Session.GetDetailedReport(togglWorkspaceID, since, until, 1) |
||
361 | if err != nil { |
||
362 | c.Logger.Printf("[ERROR] Toggl: %s", err) |
||
363 | } |
||
364 | |||
365 | entries = c.ReportToTogglPlanfixEntry(report) |
||
366 | return entries, nil |
||
367 | } |
||
368 | |||
369 | func (c TogglClient) GetReport(rp toggl.DetailedReportParams) toggl.DetailedReport { |
||
370 | rp.WorkspaceID = c.Config.TogglWorkspaceID |
||
371 | rp.Rounding = true |
||
372 | report, err := c.Session.GetDetailedReportV2(rp) |
||
373 | if err != nil { |
||
374 | c.Logger.Printf("[ERROR] Toggl: %s", err) |
||
375 | } |
||
376 | return report |
||
377 | } |
||
378 | |||
379 | func (c TogglClient) GetReportV1(togglWorkspaceID int, since, until string, page int) toggl.DetailedReport { |
||
380 | report, err := c.Session.GetDetailedReport(togglWorkspaceID, since, until, page) |
||
381 | if err != nil { |
||
382 | c.Logger.Printf("[ERROR] Toggl: %s", err) |
||
383 | } |
||
384 | return report |
||
385 | } |
||
386 | |||
387 | func (c TogglClient) getDetailedReportParams() { |
||
388 | return |
||
389 | } |
||
390 | |||
391 | func (c TogglClient) GetEntriesV2(rp toggl.DetailedReportParams) (entries []TogglPlanfixEntry, err error) { |
||
392 | report := c.GetReport(rp) |
||
393 | entries = c.ReportToTogglPlanfixEntry(report) |
||
394 | return entries, nil |
||
395 | } |
||
396 | |||
397 | func (c TogglClient) GetEntriesByTag(tagName string) (entries []TogglPlanfixEntry, err error) { |
||
398 | tag, err := c.Session.GetTagByName(tagName, c.Config.TogglWorkspaceID) |
||
399 | if err != nil { |
||
400 | return entries, err |
||
401 | } |
||
402 | report := c.GetReport(toggl.DetailedReportParams{ |
||
403 | // TODO: defaultReportParams |
||
404 | TagIDs: []int{tag.ID}, |
||
405 | }) |
||
406 | entries = c.ReportToTogglPlanfixEntry(report) |
||
407 | return entries, nil |
||
408 | } |
||
409 | |||
410 | // filter - хэлпер, фильтрующий toggl-записи |
||
411 | func filter(input []TogglPlanfixEntry, f func(entry TogglPlanfixEntry) bool) (output []TogglPlanfixEntry) { |
||
412 | for _, v := range input { |
||
413 | if f(v) { |
||
414 | output = append(output, v) |
||
415 | } |
||
416 | } |
||
417 | return output |
||
418 | } |
||
419 | |||
420 | // GetPendingEntriesPage возвращает toggl-записи, которые должны быть отправлены в Планфикс для конкретной страницы |
||
421 | func (c TogglClient) getPendingEntriesPage(page int) (entries []TogglPlanfixEntry, err error) { |
||
422 | entries, err = c.GetEntriesV2(toggl.DetailedReportParams{ |
||
423 | Page: page, |
||
424 | }) |
||
425 | if err != nil { |
||
426 | return []TogglPlanfixEntry{}, err |
||
427 | } |
||
428 | entries = filter(entries, func(entry TogglPlanfixEntry) bool { return entry.Planfix.TaskID != 0 }) |
||
429 | entries = filter(entries, func(entry TogglPlanfixEntry) bool { return !entry.Planfix.Sent }) |
||
430 | entries = filter(entries, func(entry TogglPlanfixEntry) bool { return entry.Uid == c.Config.TogglUserID }) |
||
431 | return entries, nil |
||
432 | } |
||
433 | |||
434 | // GetPendingEntries возвращает toggl-записи, которые должны быть отправлены в Планфикс |
||
435 | func (c TogglClient) GetPendingEntries() (entries []TogglPlanfixEntry, err error) { |
||
436 | maxPages := 20 |
||
437 | for currentPage := 1; currentPage <= maxPages; currentPage++ { |
||
438 | pageEntries, err := c.getPendingEntriesPage(currentPage) |
||
439 | if err != nil { |
||
440 | return entries, err |
||
441 | } |
||
442 | if len(pageEntries) == 0 { |
||
443 | break |
||
444 | } |
||
445 | entries = append(entries, pageEntries...) |
||
446 | } |
||
447 | return entries, err |
||
448 | } |
||
449 | |||
450 | // sendEntries отправляет toggl-записи в Планфикс и помечает их в Toggl тегом sent |
||
451 | func (c TogglClient) sendEntries(planfixTaskID int, entries []TogglPlanfixEntry) (error, int) { |
||
452 | // будет точно просуммировано в одну |
||
453 | sumEntry := c.SumEntriesGroup(map[int][]TogglPlanfixEntry{ |
||
454 | planfixTaskID: entries, |
||
455 | })[0] |
||
456 | |||
457 | date := sumEntry.Start.Format("2006-01-02") |
||
458 | mins := int(math.Floor(float64(sumEntry.Duration)/60000 + .5)) |
||
459 | entryString := fmt.Sprintf( |
||
460 | "[%s] %s (%d)", |
||
461 | sumEntry.Project, |
||
462 | sumEntry.Description, |
||
463 | mins, |
||
464 | ) |
||
465 | comment := fmt.Sprintf("toggl: %s", entryString) |
||
466 | |||
467 | if c.Config.Debug { |
||
468 | c.Logger.Printf("[DEBUG] sending %s", entryString) |
||
469 | } |
||
470 | if c.Config.DryRun { |
||
471 | c.Logger.Println("[DEBUG] dry-run") |
||
472 | return nil, mins |
||
473 | } |
||
474 | |||
475 | // send to planfix |
||
476 | var err error |
||
477 | if c.Config.PlanfixUserID != 0 { |
||
478 | err = c.sendWithPlanfixAPI(planfixTaskID, date, mins, comment) |
||
479 | } else { |
||
480 | err = c.sendWithSMTP(planfixTaskID, date, mins) |
||
481 | } |
||
482 | if err != nil { |
||
483 | c.Logger.Printf("[ERROR] %v", err) |
||
484 | return err, mins |
||
485 | } |
||
486 | |||
487 | return c.markAsSent(entries), mins |
||
488 | } |
||
489 | |||
490 | // markAsSent отмечает toggl-записи тегом sent |
||
491 | func (c TogglClient) markAsSent(entries []TogglPlanfixEntry) error { |
||
492 | for _, entry := range entries { |
||
493 | entryString := fmt.Sprintf( |
||
494 | "[%s] %s (%d)", |
||
495 | entry.Project, |
||
496 | entry.Description, |
||
497 | int(math.Floor(float64(entry.Duration)/60000+.5)), |
||
498 | ) |
||
499 | if c.Config.Debug { |
||
500 | c.Logger.Printf("[DEBUG] marking %s in toggl", entryString) |
||
501 | } |
||
502 | if _, err := c.Session.AddRemoveTag(entry.ID, c.Config.TogglSentTag, true); err != nil { |
||
503 | c.Logger.Println(err) |
||
504 | return err |
||
505 | } |
||
506 | } |
||
507 | return nil |
||
508 | } |
||
509 | |||
510 | // sendWithSMTP отправляет toggl-запись через SMTP |
||
511 | func (c TogglClient) sendWithSMTP(planfixTaskID int, date string, mins int) error { |
||
512 | auth := smtp.PlainAuth("", c.Config.SMTPLogin, c.Config.SMTPPassword, c.Config.SMTPHost) |
||
513 | taskEmail := c.getTaskEmail(planfixTaskID) |
||
514 | to := []string{taskEmail} |
||
515 | if c.Config.Debug { |
||
516 | testEmail := c.Config.SMTPEmailFrom |
||
517 | to = append(to, testEmail) |
||
518 | } |
||
519 | body := c.getEmailBody(planfixTaskID, date, mins) |
||
520 | msg := []byte(body) |
||
521 | return smtp.SendMail(fmt.Sprintf("%s:%d", c.Config.SMTPHost, c.Config.SMTPPort), auth, c.Config.SMTPEmailFrom, to, msg) |
||
522 | } |
||
523 | |||
524 | // getTaskEmail возвращает email задачи по ее номеру |
||
525 | func (c TogglClient) getTaskEmail(planfixTaskID int) string { |
||
526 | return fmt.Sprintf("task+%d@%s.planfix.ru", planfixTaskID, c.Config.PlanfixAccount) |
||
527 | } |
||
528 | |||
529 | // getEmailBody возвращает email body для отправки в Планфикс |
||
530 | func (c TogglClient) getEmailBody(planfixTaskID int, date string, mins int) string { |
||
531 | taskEmail := c.getTaskEmail(planfixTaskID) |
||
532 | return fmt.Sprintf( |
||
533 | "Content-Type: text/plain; charset=\"utf-8\"\r\n"+ |
||
534 | "From: %s\r\n"+ |
||
535 | "To: %s\r\n"+ |
||
536 | "Subject: @toggl @nonotify\r\n"+ |
||
537 | "\r\n"+ |
||
538 | "Вид работы: %s\r\n"+ |
||
539 | "time: %d\r\n"+ |
||
540 | "Автор: %s\r\n"+ |
||
541 | "Дата: %s\r\n", |
||
542 | c.Config.SMTPEmailFrom, |
||
543 | taskEmail, |
||
544 | c.Config.PlanfixAnaliticTypeValue, |
||
545 | mins, |
||
546 | c.Config.PlanfixAuthorName, |
||
547 | date, |
||
548 | ) |
||
549 | } |
||
550 | |||
551 | // sendWithPlanfixAPI отправляет toggl-запись через Планфикс API |
||
552 | func (c TogglClient) sendWithPlanfixAPI(planfixTaskID int, date string, mins int, comment string) error { |
||
553 | userIDs := struct { |
||
554 | ID []int `xml:"id"` |
||
555 | }{[]int{c.Config.PlanfixUserID}} |
||
556 | |||
557 | _, err := c.PlanfixAPI.ActionAdd(planfix.XMLRequestActionAdd{ |
||
558 | TaskGeneral: planfixTaskID, |
||
559 | Description: "", |
||
560 | Analitics: []planfix.XMLRequestActionAnalitic{ |
||
561 | { |
||
562 | ID: c.analiticData.ID, |
||
563 | // аналитика должна содержать поля: вид работы, кол-во, дата, коммент, юзеры |
||
564 | ItemData: []planfix.XMLRequestAnaliticField{ |
||
565 | {FieldID: c.analiticData.TypeID, Value: c.analiticData.TypeValueID}, // name |
||
566 | {FieldID: c.analiticData.CountID, Value: mins}, // count, минут |
||
567 | {FieldID: c.analiticData.CommentID, Value: comment}, // comment |
||
568 | {FieldID: c.analiticData.DateID, Value: date}, // date |
||
569 | {FieldID: c.analiticData.UsersID, Value: userIDs}, // user |
||
570 | }, |
||
571 | }, |
||
572 | }, |
||
573 | }) |
||
574 | return err |
||
575 | } |
||
576 | |||
577 | // GetAnaliticDataCached получает аналитику из кеша (по возможности) |
||
578 | func (c *TogglClient) GetAnaliticDataCached(name, typeName, typeValue, countName, commentName, dateName, usersName string) (PlanfixAnaliticData, error) { |
||
579 | if c.analiticData.ID != 0 { // only on first call |
||
580 | return c.analiticData, nil |
||
581 | } |
||
582 | data, err := c.GetAnaliticData(name, typeName, typeValue, countName, commentName, dateName, usersName) |
||
583 | c.analiticData = data |
||
584 | return c.analiticData, err |
||
585 | } |
||
586 | |||
587 | // GetAnaliticData получает ID аналитики и ее полей из названий аналитики и полей |
||
588 | func (c *TogglClient) GetAnaliticData(name, typeName, typeValue, countName, commentName, dateName, usersName string) (PlanfixAnaliticData, error) { |
||
589 | // получение аналитики |
||
590 | analitic, err := c.PlanfixAPI.GetAnaliticByName(name) |
||
591 | if err != nil { |
||
592 | return PlanfixAnaliticData{}, err |
||
593 | } |
||
594 | |||
595 | // получение полей аналитики |
||
596 | analiticOptions, err := c.PlanfixAPI.AnaliticGetOptions(analitic.ID) |
||
597 | if err != nil { |
||
598 | return PlanfixAnaliticData{}, err |
||
599 | } |
||
600 | |||
601 | analiticData := PlanfixAnaliticData{ |
||
602 | ID: analitic.ID, |
||
603 | } |
||
604 | |||
605 | // получение ID полей по их названиям |
||
606 | for _, field := range analiticOptions.Analitic.Fields { |
||
607 | switch field.Name { |
||
608 | case typeName: |
||
609 | analiticData.TypeID = field.ID |
||
610 | // получение ID записи справочника |
||
611 | record, _ := c.PlanfixAPI.GetHandbookRecordByName(field.HandbookID, typeValue) |
||
612 | analiticData.TypeValueID = record.Key |
||
613 | case countName: |
||
614 | analiticData.CountID = field.ID |
||
615 | case commentName: |
||
616 | analiticData.CommentID = field.ID |
||
617 | case dateName: |
||
618 | analiticData.DateID = field.ID |
||
619 | case usersName: |
||
620 | analiticData.UsersID = field.ID |
||
621 | } |
||
622 | } |
||
623 | |||
624 | err = c.isAnaliticValid(analiticData) |
||
625 | return analiticData, err |
||
626 | } |
||
627 | |||
628 | // isAnaliticValid проходит по всем полям структуры PlanfixAnaliticData и возвращает ошибку, если хоть один ID == 0 |
||
629 | func (c TogglClient) isAnaliticValid(data PlanfixAnaliticData) error { |
||
630 | var errors []string |
||
631 | v := reflect.ValueOf(data) |
||
632 | typeOf := v.Type() |
||
633 | //values := make([]interface{}, v.NumField()) |
||
634 | for i := 0; i < v.NumField(); i++ { |
||
635 | name := typeOf.Field(i).Name |
||
636 | value := v.Field(i).Int() |
||
637 | if value == 0 { |
||
638 | errors = append(errors, fmt.Sprintf("%s not found", name)) |
||
639 | } |
||
640 | } |
||
641 | if len(errors) != 0 { |
||
642 | return fmt.Errorf(strings.Join(errors, ", ")) |
||
0 ignored issues
–
show
introduced
by
![]() |
|||
643 | } |
||
644 | return nil |
||
645 | } |
||
646 | |||
647 | func (c TogglClient) Notify(msg string) error { |
||
648 | err := beeep.Notify("", msg, "assets/icon.png") |
||
649 | return err |
||
650 | } |
||
651 |