Passed
Push — master ( 495634...373907 )
by Stanislav
01:20
created

client.TogglClient.markAsSent   A

Complexity

Conditions 4

Size

Total Lines 15
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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