Skip to content

Commit c80a20b

Browse files
authored
Enable export to PDF and copying of attachments (#11)
Use wkhtmltopdf for PDF support because it has the easiest emoji solution (via twemoji). heic2jpg.go is untested as it is largely copied from upstream, and I have a PR open to create an interface upstream: adrium/goheif#2 goheif also has issues vendoring because there is no Go code in some of the required C++ subdirectories: golang/go#26366. This should also be fixed upstream, but as a stopgap, we need to use the cp and chmod commands (vendor makefile target). * Enable output to PDF * Add support or jpegs in PDFs * Switch to wkhtmltopdf for emoji support * Implement copying attachments * Convert HEIC attachments to JPEG for PDF output * Update example to match new defaults * Add "attached" text to attachment references in outfiles * Update dependencies * Install wkhtmltopdf in Travis CI * Use xvfb in Travis CI
1 parent 336899a commit c80a20b

24 files changed

+1977
-380
lines changed

.travis.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
language: go
22
go:
33
- 1.x
4+
before_install:
5+
- sudo apt-get -y install wkhtmltopdf
46
install:
57
- go mod download
68
script:
7-
- make test
9+
- xvfb-run make test
810
after_success:
911
- make codecov

Makefile

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
COVERAGE_FILE=coverage.out
22
ZIPFILE="bagoup-$(shell uname -s)-$(shell uname -m).zip"
3+
GOHEIF_VENDOR_DIR=vendor/github.com/adrium/goheif
4+
LIBDE265_VENDOR_DIR=$(GOHEIF_VENDOR_DIR)/libde265
35

46
build: bagoup
57

6-
bagoup: main.go opsys/opsys.go chatdb/chatdb.go vendor
8+
bagoup: main.go opsys/opsys.go opsys/outfile.go opsys/templates/* chatdb/chatdb.go pathtools/pathtools.go vendor
79
go build -o $@ $<
810

911
vendor: go.mod go.sum
1012
go mod vendor -v
13+
rm -vrf $(LIBDE265_VENDOR_DIR)
14+
@echo "Copy files pruned by `go mod vendor` (see https://github.com/golang/go/issues/26366). Sudo permissions will be required"
15+
cp -vR $(shell go env GOPATH)/pkg/mod/github.com/adrium/[email protected]/libde265 $(GOHEIF_VENDOR_DIR)
16+
sudo chmod -vR u+rw $(LIBDE265_VENDOR_DIR)
17+
chmod -v u+x $(LIBDE265_VENDOR_DIR)
1118

1219
.PHONY: deps generate test zip clean codecov
1320

README.md

+32-25
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
bagoup *(pronounced BAAGoop)* is an export utility for Mac OS Messages,
44
implemented in Go, inspired by
55
[Baskup](http://peterkaminski09.github.io/baskup/). It exports all of the
6-
conversations saved in Messages to readable, searchable text files.
6+
conversations saved in Messages to readable, searchable text or PDF files.
77

88
## Example Export
99
```
10-
$ cat "backup/Novak Djokovic/iMessage;-;+3815555555555.txt"
10+
$ cat "messages-export/Novak Djokovic/iMessage;-;+3815555555555.txt"
1111
[2020-03-01 15:34:05] Me: Want to play tennis?
1212
[2020-03-01 15:34:41] Novak: I can't today. I'm still at the Dubai Open
1313
[2020-03-01 15:34:53] Me: Ah, okay. When are you back in SF?
@@ -22,11 +22,28 @@ brew tap tagatac/bagoup
2222
brew install bagoup
2323
```
2424

25-
## chat.db Access
25+
## Protected File Access
2626
The Messages database is a protected file in Mac OS. See
2727
[this article](https://appletoolbox.com/seeing-error-operation-not-permitted-in-macos-mojave/)
28-
for more details. To to backup your messages, you have two options:
29-
### Option 1 (recommended): Copy chat.db
28+
for more details. Additionally, attachments can be located in various protected
29+
places on your filesystem.
30+
31+
To to backup your messages, you have two options. If you wish to export to PDFs
32+
with images (`--pdf`), or to copy attachments (`--copy-attachments`), you must use
33+
the first option.
34+
### Option 1 (required for attachments): Give your terminal emulator full disk access
35+
From [osxdaily.com](https://osxdaily.com/2018/10/09/fix-operation-not-permitted-terminal-error-macos/):
36+
1. Pull down the Apple menu and choose "System Preferences"
37+
1. Choose "Security & Privacy" control panel
38+
1. Now select the "Privacy" tab, then from the left-side menu select "Full Disk Access"
39+
1. Click the lock icon in the lower left corner of the preference panel and authenticate with an admin level login
40+
1. Now click the [+] plus button to add an application with full disk access
41+
1. Navigate to the /Applications/Utilities/ folder and choose "Terminal" (or your terminal emulator of choice) to grant your terminal with Full Disk Access privileges
42+
1. Relaunch your terminal emulator, and the “Operation not permitted” error messages will be gone
43+
44+
If you choose this option, bagoup will be able to open **chat.db** in its
45+
default location, and the `--db-path` flag is not needed.
46+
### Option 2 (more secure if attachments are not desired): Copy chat.db
3047
Copy the Messages database to an unprotected folder in Finder, and provide the
3148
path to the copy via the `--db-path` flag.
3249

@@ -37,19 +54,6 @@ path to the copy via the `--db-path` flag.
3754
1. Right-click in the unprotected folder, and click **Paste Item** in the
3855
context menu.
3956

40-
### Option 2 (less secure): Give your terminal full disk access
41-
From [osxdaily.com](https://osxdaily.com/2018/10/09/fix-operation-not-permitted-terminal-error-macos/):
42-
1. Pull down the Apple menu and choose ‘System Preferences’
43-
1. Choose “Security & Privacy” control panel
44-
1. Now select the “Privacy” tab, then from the left-side menu select “Full Disk Access”
45-
1. Click the lock icon in the lower left corner of the preference panel and authenticate with an admin level login
46-
1. Now click the [+] plus button to add an application with full disk access
47-
1. Navigate to the /Applications/Utilities/ folder and choose “Terminal” to grant Terminal with Full Disk Access privileges
48-
1. Relaunch Terminal, the “Operation not permitted” error messages will be gone
49-
50-
If you choose this option, bagoup will be able to open **chat.db** in its
51-
default location, and the `--db-path` flag is not needed.
52-
5357
## Contact Information (optional)
5458
If you provide your contacts via the `--contacts-path` flag, bagoup will attempt
5559
to match the handles from the Messages database with full names from your
@@ -65,15 +69,18 @@ Usage:
6569
bagoup [OPTIONS]
6670
6771
Application Options:
68-
-i, --db-path= Path to the Messages chat database file (default: ~/Library/Messages/chat.db)
69-
-o, --export-path= Path to which the Messages will be exported (default: backup)
70-
-m, --mac-os-version= Version of Mac OS, e.g. '10.15', from which the Messages chat database file was copied (not needed if bagoup is running on the same Mac)
71-
-c, --contacts-path= Path to the contacts vCard file
72-
-s, --self-handle= Prefix to use for for messages sent by you (default: Me)
73-
--separate-chats Do not merge chats with the same contact into a single file, e.g. iMessage and SMS
72+
-i, --db-path= Path to the Messages chat database file (default: ~/Library/Messages/chat.db)
73+
-o, --export-path= Path to which the Messages will be exported (default: messages-export)
74+
-m, --mac-os-version= Version of Mac OS, e.g. '10.15', from which the Messages chat database file was copied (not needed if bagoup is running on the same Mac)
75+
-c, --contacts-path= Path to the contacts vCard file
76+
-s, --self-handle= Prefix to use for for messages sent by you (default: Me)
77+
--separate-chats Do not merge chats with the same contact (e.g. iMessage and SMS) into a single file
78+
-p, --pdf Export text and images to PDF files (requires full disk access)
79+
--include-ppa Include plugin payload attachments (e.g. link previews) in generated PDFs
80+
-a, --copy-attachments Copy attachments to the same folder as the chat which included them (requires full disk access)
7481
7582
Help Options:
76-
-h, --help Show this help message
83+
-h, --help Show this help message
7784
```
7885
All conversations will be exported as text files to the specified export path.
7986
See https://github.com/tagatac/bagoup/tree/master/example-export for an example

chatdb/chatdb.go

+52
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ package chatdb
1212
import (
1313
"database/sql"
1414
"fmt"
15+
"path/filepath"
1516
"sort"
1617
"strconv"
18+
"strings"
1719

1820
"github.com/Masterminds/semver"
1921
"github.com/emersion/go-vcard"
2022
"github.com/pkg/errors"
23+
"github.com/tagatac/bagoup/pathtools"
2124
)
2225

2326
const _githubIssueMsg = "open an issue at https://github.com/tagatac/bagoup/issues"
@@ -63,6 +66,9 @@ type (
6366
// GetMessage returns a message retrieved from the database formatted for
6467
// writing to a chat file.
6568
GetMessage(messageID int, handleMap map[int]string, macOSVersion *semver.Version) (string, error)
69+
// GetImagePaths returns a list of attachment filepaths associated with
70+
// each message ID.
71+
GetAttachmentPaths() (map[int][]string, error)
6672
}
6773

6874
// DatedMessageID pairs a message ID and its date, in the legacy date format.
@@ -233,3 +239,49 @@ func (d *chatDB) getDatetimeFormula(macOSVersion *semver.Version) string {
233239
}
234240
return _datetimeFormula
235241
}
242+
243+
func (d *chatDB) GetAttachmentPaths() (map[int][]string, error) {
244+
attachmentJoins, err := d.DB.Query("SELECT message_id, attachment_id FROM message_attachment_join")
245+
if err != nil {
246+
return nil, errors.Wrapf(err, "scan message_attachment_join table")
247+
}
248+
defer attachmentJoins.Close()
249+
250+
attPaths := map[int][]string{}
251+
for attachmentJoins.Next() {
252+
var msgID, attID int
253+
if err := attachmentJoins.Scan(&msgID, &attID); err != nil {
254+
return attPaths, errors.Wrap(err, "read data from message_attachment_join table")
255+
}
256+
filename, _, err := d.getAttachmentPath(attID)
257+
if err != nil {
258+
return attPaths, errors.Wrapf(err, "get path for attachment %d to message %d", attID, msgID)
259+
}
260+
attPaths[msgID] = append(attPaths[msgID], filename)
261+
}
262+
return attPaths, nil
263+
}
264+
265+
func (d *chatDB) getAttachmentPath(attachmentID int) (string, string, error) {
266+
attachments, err := d.DB.Query(fmt.Sprintf("SELECT filename, COALESCE(mime_type, '') FROM attachment WHERE ROWID=%d", attachmentID))
267+
if err != nil {
268+
return "", "", errors.Wrapf(err, "query attachment table for ID %d", attachmentID)
269+
}
270+
defer attachments.Close()
271+
attachments.Next()
272+
var filename, mimeType string
273+
if err := attachments.Scan(&filename, &mimeType); err != nil {
274+
return "", "", errors.Wrapf(err, "read data for attachment ID %d", attachmentID)
275+
}
276+
if attachments.Next() {
277+
return "", "", fmt.Errorf("multiple attachments with the same ID: %d - attachment ID uniqueness assumption violated - %s", attachmentID, _githubIssueMsg)
278+
}
279+
filename, err = pathtools.ReplaceTilde(filename)
280+
if err != nil {
281+
return "", "", errors.Wrap(err, "replace tilde")
282+
}
283+
if strings.HasPrefix(filename, "/var") {
284+
filename = filepath.Join(filepath.Dir(filename), "0", filepath.Base(filename))
285+
}
286+
return filename, mimeType, nil
287+
}

chatdb/chatdb_test.go

+123-5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"testing"
99

1010
"github.com/Masterminds/semver"
11+
"github.com/tagatac/bagoup/pathtools"
1112

1213
"github.com/DATA-DOG/go-sqlmock"
1314
"github.com/emersion/go-vcard"
@@ -95,7 +96,7 @@ func TestGetHandleMap(t *testing.T) {
9596
cdb := NewChatDB(db, "Me")
9697
handleMap, err := cdb.GetHandleMap(tt.contactMap)
9798
if tt.wantErr != "" {
98-
assert.ErrorContains(t, err, tt.wantErr)
99+
assert.Error(t, err, tt.wantErr)
99100
return
100101
}
101102
assert.NilError(t, err)
@@ -217,7 +218,7 @@ func TestGetChats(t *testing.T) {
217218

218219
chats, err := cdb.GetChats(tt.contactMap)
219220
if tt.wantErr != "" {
220-
assert.ErrorContains(t, err, tt.wantErr)
221+
assert.Error(t, err, tt.wantErr)
221222
return
222223
}
223224
assert.NilError(t, err)
@@ -276,7 +277,7 @@ func TestGetMessageIDs(t *testing.T) {
276277

277278
ids, err := cdb.GetMessageIDs(42)
278279
if tt.wantErr != "" {
279-
assert.ErrorContains(t, err, tt.wantErr)
280+
assert.Error(t, err, tt.wantErr)
280281
return
281282
}
282283
assert.NilError(t, err)
@@ -328,7 +329,7 @@ func TestGetMessage(t *testing.T) {
328329
AddRow(0, nil, "message text", "2019-10-04 18:26:31")
329330
query.WillReturnRows(rows)
330331
},
331-
wantErr: "read data for message ID 42: sql: Scan error on column index 1, name \"handle_id\": converting NULL to int is unsupported",
332+
wantErr: `read data for message ID 42: sql: Scan error on column index 1, name "handle_id": converting NULL to int is unsupported`,
332333
},
333334
{
334335
msg: "duplicate message ID",
@@ -353,7 +354,7 @@ func TestGetMessage(t *testing.T) {
353354

354355
message, err := cdb.GetMessage(42, handleMap, nil)
355356
if tt.wantErr != "" {
356-
assert.ErrorContains(t, err, tt.wantErr)
357+
assert.Error(t, err, tt.wantErr)
357358
return
358359
}
359360
assert.NilError(t, err)
@@ -402,3 +403,120 @@ func TestGetDatetimeFormula(t *testing.T) {
402403
})
403404
}
404405
}
406+
407+
func TestGetAttachmentPaths(t *testing.T) {
408+
tests := []struct {
409+
msg string
410+
setupMock func(sqlmock.Sqlmock)
411+
wantAttPaths map[int][]string
412+
wantErr string
413+
}{
414+
{
415+
msg: "two attachments to one message, one to another",
416+
setupMock: func(sMock sqlmock.Sqlmock) {
417+
rows := sqlmock.NewRows([]string{"message_id", "attachment_id"}).
418+
AddRow(1, 1).
419+
AddRow(1, 2).
420+
AddRow(2, 3)
421+
sMock.ExpectQuery("SELECT message_id, attachment_id FROM message_attachment_join").WillReturnRows(rows)
422+
rows = sqlmock.NewRows([]string{"filename", "mime_type"}).
423+
AddRow("attachment1.jpeg", "image/jpeg")
424+
sMock.ExpectQuery(`SELECT filename, COALESCE\(mime_type, ''\) FROM attachment WHERE ROWID\=1`).WillReturnRows(rows)
425+
rows = sqlmock.NewRows([]string{"filename", "mime_type"}).
426+
AddRow("~/attachment2.heic", "image/heic")
427+
sMock.ExpectQuery(`SELECT filename, COALESCE\(mime_type, ''\) FROM attachment WHERE ROWID\=2`).WillReturnRows(rows)
428+
rows = sqlmock.NewRows([]string{"filename", "mime_type"}).
429+
AddRow("/var/folder/attachment3.mp4", "video/mp4")
430+
sMock.ExpectQuery(`SELECT filename, COALESCE\(mime_type, ''\) FROM attachment WHERE ROWID\=3`).WillReturnRows(rows)
431+
},
432+
wantAttPaths: map[int][]string{
433+
1: {"attachment1.jpeg", pathtools.MustReplaceTilde("~/attachment2.heic")},
434+
2: {"/var/folder/0/attachment3.mp4"},
435+
},
436+
},
437+
{
438+
msg: "join table query error",
439+
setupMock: func(sMock sqlmock.Sqlmock) {
440+
sMock.ExpectQuery("SELECT message_id, attachment_id FROM message_attachment_join").WillReturnError(errors.New("this is a DB error"))
441+
},
442+
wantErr: "scan message_attachment_join table: this is a DB error",
443+
},
444+
{
445+
msg: "join table row scan error",
446+
setupMock: func(sMock sqlmock.Sqlmock) {
447+
rows := sqlmock.NewRows([]string{"message_id", "attachment_id"}).
448+
AddRow(1, nil)
449+
sMock.ExpectQuery("SELECT message_id, attachment_id FROM message_attachment_join").WillReturnRows(rows)
450+
},
451+
wantErr: `read data from message_attachment_join table: sql: Scan error on column index 1, name "attachment_id": converting NULL to int is unsupported`,
452+
},
453+
{
454+
msg: "attachments table query error",
455+
setupMock: func(sMock sqlmock.Sqlmock) {
456+
rows := sqlmock.NewRows([]string{"message_id", "attachment_id"}).
457+
AddRow(1, 1).
458+
AddRow(1, 2).
459+
AddRow(2, 3)
460+
sMock.ExpectQuery("SELECT message_id, attachment_id FROM message_attachment_join").WillReturnRows(rows)
461+
rows = sqlmock.NewRows([]string{"filename", "mime_type"}).
462+
AddRow("attachment1.jpeg", "image/jpeg")
463+
sMock.ExpectQuery(`SELECT filename, COALESCE\(mime_type, ''\) FROM attachment WHERE ROWID\=1`).WillReturnRows(rows)
464+
sMock.ExpectQuery(`SELECT filename, COALESCE\(mime_type, ''\) FROM attachment WHERE ROWID\=2`).WillReturnError(errors.New("this is a DB error"))
465+
},
466+
wantErr: "get path for attachment 2 to message 1: query attachment table for ID 2: this is a DB error",
467+
},
468+
{
469+
msg: "attachments table row scan error",
470+
setupMock: func(sMock sqlmock.Sqlmock) {
471+
rows := sqlmock.NewRows([]string{"message_id", "attachment_id"}).
472+
AddRow(1, 1).
473+
AddRow(1, 2).
474+
AddRow(2, 3)
475+
sMock.ExpectQuery("SELECT message_id, attachment_id FROM message_attachment_join").WillReturnRows(rows)
476+
rows = sqlmock.NewRows([]string{"filename", "mime_type"}).
477+
AddRow("attachment1.jpeg", "image/jpeg")
478+
sMock.ExpectQuery(`SELECT filename, COALESCE\(mime_type, ''\) FROM attachment WHERE ROWID\=1`).WillReturnRows(rows)
479+
rows = sqlmock.NewRows([]string{"filename", "mime_type"}).
480+
AddRow("~/attachment2.heic", nil)
481+
sMock.ExpectQuery(`SELECT filename, COALESCE\(mime_type, ''\) FROM attachment WHERE ROWID\=2`).WillReturnRows(rows)
482+
},
483+
wantErr: `get path for attachment 2 to message 1: read data for attachment ID 2: sql: Scan error on column index 1, name "mime_type": converting NULL to string is unsupported`,
484+
},
485+
{
486+
msg: "duplicate attachment ID",
487+
setupMock: func(sMock sqlmock.Sqlmock) {
488+
rows := sqlmock.NewRows([]string{"message_id", "attachment_id"}).
489+
AddRow(1, 1).
490+
AddRow(1, 2).
491+
AddRow(2, 3)
492+
sMock.ExpectQuery("SELECT message_id, attachment_id FROM message_attachment_join").WillReturnRows(rows)
493+
rows = sqlmock.NewRows([]string{"filename", "mime_type"}).
494+
AddRow("attachment1.jpeg", "image/jpeg")
495+
sMock.ExpectQuery(`SELECT filename, COALESCE\(mime_type, ''\) FROM attachment WHERE ROWID\=1`).WillReturnRows(rows)
496+
rows = sqlmock.NewRows([]string{"filename", "mime_type"}).
497+
AddRow("~/attachment2.heic", "image/heic").
498+
AddRow("attachment2.mov", "video/mov")
499+
sMock.ExpectQuery(`SELECT filename, COALESCE\(mime_type, ''\) FROM attachment WHERE ROWID\=2`).WillReturnRows(rows)
500+
},
501+
wantErr: "get path for attachment 2 to message 1: multiple attachments with the same ID: 2 - attachment ID uniqueness assumption violated - open an issue at https://github.com/tagatac/bagoup/issues",
502+
},
503+
}
504+
505+
for _, tt := range tests {
506+
t.Run(tt.msg, func(t *testing.T) {
507+
db, sMock, err := sqlmock.New()
508+
assert.NilError(t, err)
509+
defer db.Close()
510+
tt.setupMock(sMock)
511+
cdb := &chatDB{DB: db}
512+
513+
attPaths, err := cdb.GetAttachmentPaths()
514+
if tt.wantErr != "" {
515+
assert.Error(t, err, tt.wantErr)
516+
return
517+
}
518+
assert.NilError(t, err)
519+
assert.DeepEqual(t, tt.wantAttPaths, attPaths)
520+
})
521+
}
522+
}

chatdb/mock_chatdb/mock_chatdb.go

+15
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)