Completed
Push — master ( 7ca05e...d13f3d )
by Stanislav
01:39
created

client.TogglClient.sendWithPlanfixAPI   A

Complexity

Conditions 2

Size

Total Lines 34
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
dl 0
loc 34
rs 9.256
c 0
b 0
f 0
eloc 26
nop 4
1
package client
2
3
import (
4
	"fmt"
5
	"github.com/popstas/go-toggl"
6
	"github.com/popstas/planfix-go/planfix"
7
	"github.com/viasite/planfix-toggl-server/app/config"
8
	"log"
9
	"math"
10
	"net/smtp"
11
	"regexp"
12
	"strconv"
13
	"time"
14
	"sort"
15
	"reflect"
16
	"strings"
17
)
18
19
type TogglSession interface {
0 ignored issues
show
introduced by
exported type TogglSession should have comment or be unexported
Loading history...
20
	GetAccount() (toggl.Account, error)
21
	AddRemoveTag(entryID int, tag string, add bool) (toggl.TimeEntry, error)
22
	GetCurrentTimeEntry() (toggl.TimeEntry, error)
23
	GetDetailedReport(workspace int, since, until string, page int) (toggl.DetailedReport, error)
24
}
25
26
// TogglClient - Клиент, общающийся с Toggl и Планфиксом
27
type TogglClient struct {
28
	Session      TogglSession
29
	Config       *config.Config
30
	PlanfixAPI   planfix.API
31
	Logger       *log.Logger
32
	analiticData PlanfixAnaliticData
33
}
34
35
// PlanfixEntryData - Данные, подмешивающиеся к toggl.DetailedTimeEntry
36
type PlanfixEntryData struct {
37
	Sent       bool `json:"sent"`
38
	TaskID     int  `json:"task_id"`
39
	GroupCount int  `json:"group_count"`
40
}
41
42
// TogglPlanfixEntry - toggl.DetailedTimeEntry дополнительными данными о задаче в Планфиксе
43
type TogglPlanfixEntry struct {
44
	toggl.DetailedTimeEntry
45
	Planfix PlanfixEntryData `json:"planfix"`
46
}
47
48
// TogglPlanfixEntryGroup - группа toggl-записей, объединенных одной задачей Планфикса
49
type TogglPlanfixEntryGroup struct {
50
	Entries         []TogglPlanfixEntry
51
	Description     string
52
	Project         string
53
	ProjectHexColor string
54
	Duration        int64
55
}
56
57
// PlanfixAnaliticData - данные аналитики, которая будет проставляться в Планфикс
58
type PlanfixAnaliticData struct {
59
	ID          int
60
	TypeID      int
61
	TypeValueID int
62
	CountID     int
63
	CommentID   int
64
	DateID      int
65
	UsersID     int
66
}
67
68
// RunSender - запускалка цикла отправки накопившихся toggl-записей
69
func (c TogglClient) RunSender() {
70
	if c.Config.SendInterval <= 0 {
71
		c.Logger.Println("[INFO] No send interval, sending disabled")
72
		return
73
	}
74
75
	time.Sleep(1 * time.Second) // wait for server start
76
	for {
77
		c.SendToPlanfix()
78
		time.Sleep(time.Duration(c.Config.SendInterval) * time.Minute)
79
	}
80
}
81
82
// RunTagCleaner - запускалка цикла очистки запущенных toggl-записей от тега sent
83
func (c TogglClient) RunTagCleaner() {
84
	time.Sleep(1 * time.Second) // wait for server start
85
	for {
86
		entry, err := c.Session.GetCurrentTimeEntry()
87
		if err != nil {
88
			c.Logger.Println("[ERROR] failed to get current toggl entry")
89
			continue
90
		}
91
92
		// delete sent tag
93
		for _, tag := range entry.Tags {
94
			if tag == c.Config.TogglSentTag {
95
				c.Logger.Printf("[INFO] removed %s tag from current toggl entry", c.Config.TogglSentTag)
96
				c.Session.AddRemoveTag(entry.ID, c.Config.TogglSentTag, false)
97
			}
98
		}
99
100
		time.Sleep(1 * time.Minute)
101
	}
102
}
103
104
// SendToPlanfix получает записи из Toggl и отправляет в Планфикс
105
// * нужна, чтобы сохранился c.PlanfixAPI.Sid при авторизации
106
func (c *TogglClient) SendToPlanfix() (sumEntries []TogglPlanfixEntry, err error) {
107
	c.Logger.Println("[INFO] send to planfix")
108
	pendingEntries, err := c.GetPendingEntries()
109
	if err != nil {
110
		return []TogglPlanfixEntry{}, err
111
	}
112
	c.Logger.Printf("[INFO] found %d pending entries", len(pendingEntries))
113
	grouped := c.GroupEntriesByTask(pendingEntries)
114
	for taskID, entries := range grouped {
115
		err := c.sendEntries(taskID, entries)
116
		if err != nil {
117
			c.Logger.Printf("[ERROR] entries of task #%d failed to send", taskID)
118
		} else {
119
			c.Logger.Printf("[INFO] entries sent to https://%s.planfix.ru/task/%d", c.Config.PlanfixAccount, taskID)
120
		}
121
	}
122
	return c.SumEntriesGroup(grouped), nil
123
}
124
125
// GroupEntriesByTask объединяет плоский список toggl-записей в map c ключом - ID задачи в Планфиксе
126
func (c TogglClient) GroupEntriesByTask(entries []TogglPlanfixEntry) (grouped map[int][]TogglPlanfixEntry) {
127
	grouped = make(map[int][]TogglPlanfixEntry)
128
	if len(entries) == 0 {
129
		return grouped
130
	}
131
	for _, entry := range entries {
132
		grouped[entry.Planfix.TaskID] = append(grouped[entry.Planfix.TaskID], entry)
133
	}
134
	return grouped
135
}
136
137
// SumEntriesGroup объединяет несколько toggl-записей в одну с просуммированным временем
138
// Входной map формируется через GroupEntriesByTask
139
// Ключ массива - ID задачи в Планфиксе
140
func (c TogglClient) SumEntriesGroup(grouped map[int][]TogglPlanfixEntry) (summed []TogglPlanfixEntry) {
141
	g := make(map[int]TogglPlanfixEntry)
142
	for taskID, entries := range grouped {
143
		for _, entry := range entries {
144
			if ge, ok := g[taskID]; ok {
145
				ge.Duration += entry.Duration
146
				ge.Planfix.GroupCount++
147
				g[entry.Planfix.TaskID] = ge
148
			} else {
149
				g[entry.Planfix.TaskID] = entry
150
			}
151
		}
152
	}
153
154
	keys := make([]int, 0, len(g))
155
	for k := range g {
156
		keys = append(keys, k)
157
	}
158
	sort.Ints(keys)
159
160
	summed = make([]TogglPlanfixEntry, 0, len(g))
161
	for _, taskID := range keys {
162
		summed = append(summed, g[taskID])
163
	}
164
165
	return summed
166
}
167
168
// GetTogglUserID возвращает ID юзера в Toggl
169
func (c TogglClient) GetTogglUserID() int {
170
	account, err := c.Session.GetAccount()
171
	if err != nil {
172
		c.Logger.Fatalf("[ERROR] Failed to get Toggl UserID, check TogglAPIToken, %s", err.Error())
173
	}
174
	return account.Data.ID
175
}
176
177
// GetPlanfixUserID возвращает ID юзера в Планфиксе
178
func (c TogglClient) GetPlanfixUserID() int {
179
	var user planfix.XMLResponseUserGet
180
	user, err := c.PlanfixAPI.UserGet(0)
181
	if err != nil {
182
		c.Logger.Fatalf("[ERROR] Failed to get Planfix UserID, check PlanfixAPIKey, PlanfixAPIUrl, PlanfixUserName, PlanfixUserPassword, %s", err.Error())
183
	}
184
	return user.User.ID
185
}
186
187
// GetEntries получает []toggl.DetailedTimeEntry и превращает их в []TogglPlanfixEntry с подмешенными данными Планфикса
188
func (c TogglClient) GetEntries(togglWorkspaceID int, since, until string) (entries []TogglPlanfixEntry, err error) {
189
	report, err := c.Session.GetDetailedReport(togglWorkspaceID, since, until, 1)
190
	if err != nil {
191
		c.Logger.Printf("[ERROR] Toggl: %s", err)
192
	}
193
194
	for _, entry := range report.Data {
195
196
		pfe := TogglPlanfixEntry{
197
			DetailedTimeEntry: entry,
198
			Planfix: PlanfixEntryData{
199
				Sent:       false,
200
				TaskID:     0,
201
				GroupCount: 1,
202
			},
203
		}
204
205
		for _, tag := range entry.Tags {
206
			// only digit == planfix.task_id
207
			regex := regexp.MustCompile(`^\d+$`)
208
			if regex.MatchString(tag) {
209
				pfe.Planfix.TaskID, _ = strconv.Atoi(tag)
210
			}
211
212
			// sent tag
213
			if tag == c.Config.TogglSentTag {
214
				pfe.Planfix.Sent = true
215
			}
216
		}
217
218
		entries = append(entries, pfe)
219
	}
220
221
	return entries, nil
222
}
223
224
// filter - хэлпер, фильтрующий toggl-записи
225
func filter(input []TogglPlanfixEntry, f func(entry TogglPlanfixEntry) bool) (output []TogglPlanfixEntry) {
226
	for _, v := range input {
227
		if f(v) {
228
			output = append(output, v)
229
		}
230
	}
231
	return output
232
}
233
234
// GetPendingEntries возвращает toggl-записи, которые должны быть отправлены в Планфикс
235
func (c TogglClient) GetPendingEntries() ([]TogglPlanfixEntry, error) {
236
	entries, err := c.GetEntries(
237
		c.Config.TogglWorkspaceID,
238
		time.Now().AddDate(0, 0, -30).Format("2006-01-02"),
239
		time.Now().AddDate(0, 0, 1).Format("2006-01-02"),
240
	)
241
	if err != nil {
242
		return []TogglPlanfixEntry{}, err
243
	}
244
	entries = filter(entries, func(entry TogglPlanfixEntry) bool { return entry.Planfix.TaskID != 0 })
245
	entries = filter(entries, func(entry TogglPlanfixEntry) bool { return !entry.Planfix.Sent })
246
	entries = filter(entries, func(entry TogglPlanfixEntry) bool { return entry.Uid == c.Config.TogglUserID })
247
	return entries, nil
248
}
249
250
// sendEntries отправляет toggl-записи в Планфикс и помечает их в Toggl тегом sent
251
func (c TogglClient) sendEntries(planfixTaskID int, entries []TogglPlanfixEntry) error {
252
	// будет точно просуммировано в одну
253
	sumEntry := c.SumEntriesGroup(map[int][]TogglPlanfixEntry{
254
		planfixTaskID: entries,
255
	})[0]
256
257
	date := sumEntry.Start.Format("2006-01-02")
258
	mins := int(math.Floor(float64(sumEntry.Duration)/60000 + .5))
259
	entryString := fmt.Sprintf(
260
		"[%s] %s (%d)",
261
		sumEntry.Project,
262
		sumEntry.Description,
263
		mins,
264
	)
265
	comment := fmt.Sprintf("toggl: %s", entryString)
266
267
	c.Logger.Printf("[DEBUG] sending %s", entryString)
268
	if c.Config.DryRun {
269
		c.Logger.Println("[DEBUG] dry-run")
270
		return nil
271
	}
272
273
	// send to planfix
274
	var err error
275
	if c.Config.PlanfixUserID != 0 {
276
		err = c.sendWithPlanfixAPI(planfixTaskID, date, mins, comment)
277
	} else {
278
		err = c.sendWithSMTP(planfixTaskID, date, mins)
279
	}
280
	if err != nil {
281
		c.Logger.Printf("[ERROR] %v", err)
282
		return err
283
	}
284
285
	return c.markAsSent(entries)
286
}
287
288
// markAsSent отмечает toggl-записи тегом sent
289
func (c TogglClient) markAsSent(entries []TogglPlanfixEntry) error {
290
	for _, entry := range entries {
291
		entryString := fmt.Sprintf(
292
			"[%s] %s (%d)",
293
			entry.Project,
294
			entry.Description,
295
			int(math.Floor(float64(entry.Duration)/60000+.5)),
296
		)
297
		c.Logger.Printf("[DEBUG] marking %s in toggl", entryString)
298
		if _, err := c.Session.AddRemoveTag(entry.ID, c.Config.TogglSentTag, true); err != nil {
299
			c.Logger.Println(err)
300
			return err
301
		}
302
	}
303
	return nil
304
}
305
306
// sendWithSMTP отправляет toggl-запись через SMTP
307
func (c TogglClient) sendWithSMTP(planfixTaskID int, date string, mins int) error {
308
	auth := smtp.PlainAuth("", c.Config.SMTPLogin, c.Config.SMTPPassword, c.Config.SMTPHost)
309
	taskEmail := c.getTaskEmail(planfixTaskID)
310
	to := []string{taskEmail}
311
	if c.Config.Debug {
312
		testEmail := c.Config.SMTPEmailFrom
313
		to = append(to, testEmail)
314
	}
315
	body := c.getEmailBody(planfixTaskID, date, mins)
316
	msg := []byte(body)
317
	return smtp.SendMail(fmt.Sprintf("%s:%d", c.Config.SMTPHost, c.Config.SMTPPort), auth, c.Config.SMTPEmailFrom, to, msg)
318
}
319
320
// getTaskEmail возвращает email задачи по ее номеру
321
func (c TogglClient) getTaskEmail(planfixTaskID int) string {
322
	return fmt.Sprintf("task+%d@%s.planfix.ru", planfixTaskID, c.Config.PlanfixAccount)
323
}
324
325
// getEmailBody возвращает email body для отправки в Планфикс
326
func (c TogglClient) getEmailBody(planfixTaskID int, date string, mins int) string {
327
	taskEmail := c.getTaskEmail(planfixTaskID)
328
	return fmt.Sprintf(
329
		"Content-Type: text/plain; charset=\"utf-8\"\r\n"+
330
			"From: %s\r\n"+
331
			"To: %s\r\n"+
332
			"Subject: @toggl @nonotify\r\n"+
333
			"\r\n"+
334
			"Вид работы: %s\r\n"+
335
			"time: %d\r\n"+
336
			"Автор: %s\r\n"+
337
			"Дата: %s\r\n",
338
		c.Config.SMTPEmailFrom,
339
		taskEmail,
340
		c.Config.PlanfixAnaliticTypeValue,
341
		mins,
342
		c.Config.PlanfixAuthorName,
343
		date,
344
	)
345
}
346
347
// sendWithPlanfixAPI отправляет toggl-запись через Планфикс API
348
func (c TogglClient) sendWithPlanfixAPI(planfixTaskID int, date string, mins int, comment string) error {
349
	userIDs := struct {
350
		ID []int `xml:"id"`
351
	}{[]int{c.Config.PlanfixUserID}}
352
353
	_, err := c.PlanfixAPI.ActionAdd(planfix.XMLRequestActionAdd{
354
		TaskGeneral: planfixTaskID,
355
		Description: "",
356
		Analitics: []planfix.XMLRequestActionAnalitic{
357
			{
358
				ID: c.analiticData.ID,
359
				// аналитика должна содержать поля: вид работы, кол-во, дата, коммент, юзеры
360
				ItemData: []planfix.XMLRequestAnaliticField{
361
					{FieldID: c.analiticData.TypeID, Value: c.analiticData.TypeValueID}, // name
362
					{FieldID: c.analiticData.CountID, Value: mins},                      // count, минут
363
					{FieldID: c.analiticData.CommentID, Value: comment},                 // comment
364
					{FieldID: c.analiticData.DateID, Value: date},                       // date
365
					{FieldID: c.analiticData.UsersID, Value: userIDs},                   // user
366
				},
367
			},
368
		},
369
	})
370
	return err
371
}
372
373
// getAnaliticData получает ID аналитики и ее полей из названий аналитики и полей
0 ignored issues
show
introduced by
comment on exported method TogglClient.GetAnaliticData should be of the form "GetAnaliticData ..."
Loading history...
374
func (c *TogglClient) GetAnaliticData(name, typeName, typeValue, countName, commentName, dateName, usersName string) (PlanfixAnaliticData, error) {
375
	if c.analiticData.ID != 0 { // only on first call
376
		return c.analiticData, nil
377
	}
378
379
	// получение аналитики
380
	analitic, err := c.PlanfixAPI.GetAnaliticByName(name)
381
	if err != nil {
382
		return PlanfixAnaliticData{}, err
383
	}
384
385
	// получение полей аналитики
386
	analiticOptions, err := c.PlanfixAPI.AnaliticGetOptions(analitic.ID)
387
	if err != nil {
388
		return PlanfixAnaliticData{}, err
389
	}
390
391
	analiticData := PlanfixAnaliticData{
392
		ID: analitic.ID,
393
	}
394
395
	// получение ID полей по их названиям
396
	for _, field := range analiticOptions.Analitic.Fields {
397
		switch field.Name {
398
		case typeName:
399
			analiticData.TypeID = field.ID
400
			// получение ID записи справочника
401
			record, _ := c.PlanfixAPI.GetHandbookRecordByName(field.HandbookID, typeValue)
402
			analiticData.TypeValueID = record.Key
403
		case countName:
404
			analiticData.CountID = field.ID
405
		case commentName:
406
			analiticData.CommentID = field.ID
407
		case dateName:
408
			analiticData.DateID = field.ID
409
		case usersName:
410
			analiticData.UsersID = field.ID
411
		}
412
	}
413
414
	if err := c.isAnaliticValid(analiticData); err != nil {
415
		return analiticData, err
416
	}
417
	c.analiticData = analiticData
418
	return analiticData, nil
419
}
420
421
// isAnaliticValid проходит по всем полям структуры PlanfixAnaliticData и возвращает ошибку, если хоть один ID == 0
422
func (c TogglClient) isAnaliticValid(data PlanfixAnaliticData) error {
423
	var errors []string
424
	v := reflect.ValueOf(data)
425
	typeOf := v.Type()
426
	//values := make([]interface{}, v.NumField())
427
	for i := 0; i < v.NumField(); i++ {
428
		name := typeOf.Field(i).Name
429
		value := v.Field(i).Int()
430
		if value == 0 {
431
			errors = append(errors, fmt.Sprintf("%s not found", name))
432
		}
433
	}
434
	if len(errors) != 0 {
435
		return fmt.Errorf(strings.Join(errors, ", "))
0 ignored issues
show
introduced by
can't check non-constant format in call to Errorf
Loading history...
436
	}
437
	return nil
438
}
439