251 lines
6.8 KiB
Go
251 lines
6.8 KiB
Go
/*
|
|
Timelinize
|
|
Copyright (c) 2013 Matthew Holt
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published
|
|
by the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package iphone
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/timelinize/timelinize/datasources/imessage"
|
|
"github.com/timelinize/timelinize/timeline"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func (fimp *FileImporter) addressBook(ctx context.Context) error {
|
|
addressBookFileID, err := fimp.findFileID(ctx, homeDomain, relativePathContactsDB)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
db, err := sql.Open("sqlite3", fimp.fileIDToPath(addressBookFileID)+"?mode=ro")
|
|
if err != nil {
|
|
return fmt.Errorf("opening address book: %w", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT
|
|
p.ROWID, p.First, p.Last, p.Middle, p.Organization, p.Note,
|
|
p.Birthday, p.Nickname, p.Prefix, p.Suffix, p.CreationDate,
|
|
mv.property, mvl.value, mv.value,
|
|
mvek.value, mve.value
|
|
FROM ABPerson AS p
|
|
LEFT JOIN ABMultiValue AS mv ON mv.record_id = p.ROWID
|
|
LEFT JOIN ABMultiValueEntry AS mve ON mve.parent_id = mv.UID
|
|
LEFT JOIN ABMultiValueEntryKey AS mvek ON mvek.rowid = mve.key
|
|
LEFT JOIN ABMultiValueLabel AS mvl ON mvl.rowid = mv.label
|
|
ORDER BY p.ROWID ASC`)
|
|
if err != nil {
|
|
return fmt.Errorf("querying address book for contacts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
// a single contact may be spread across multiple rows thanks to our LEFT JOINs;
|
|
// preserve the info/state for the contact as we iterate each row
|
|
var currentContactID int64
|
|
var entity *timeline.Entity
|
|
var fields map[string]string // usually for address components, but could have other stuff too
|
|
var creationDate time.Time // when the most-recently-processing contact was added to the book
|
|
|
|
finalizeEntity := func() {
|
|
var sb strings.Builder
|
|
if v := fields["street"]; v != "" {
|
|
sb.WriteString(v) // street often contains the full address anyway
|
|
}
|
|
if v := fields["city"]; v != "" {
|
|
if sb.Len() > 0 {
|
|
sb.WriteRune('\n')
|
|
}
|
|
sb.WriteString(v)
|
|
}
|
|
if v := fields["state"]; v != "" {
|
|
if sb.Len() > 0 {
|
|
sb.WriteString(", ")
|
|
}
|
|
sb.WriteString(v)
|
|
}
|
|
if v := fields["zip"]; v != "" {
|
|
if sb.Len() > 0 {
|
|
sb.WriteString(" ")
|
|
}
|
|
sb.WriteString(v)
|
|
}
|
|
if v := fields["country"]; v != "" {
|
|
if sb.Len() > 0 {
|
|
sb.WriteString(", ")
|
|
}
|
|
sb.WriteString(v)
|
|
}
|
|
entity.Attributes = append(entity.Attributes, timeline.Attribute{
|
|
Name: "address",
|
|
Value: sb.String(),
|
|
})
|
|
|
|
g := &timeline.Graph{Entity: entity}
|
|
if !creationDate.IsZero() {
|
|
g.FromEntityWithValue(fimp.owner, timeline.Relation{Label: "saved_contact"}, strconv.FormatInt(creationDate.Unix(), 10))
|
|
}
|
|
|
|
fimp.opt.Pipeline <- g
|
|
}
|
|
|
|
for rows.Next() {
|
|
creationDate = time.Time{} // reset from previous iteration
|
|
|
|
var rowID, creationDateAppleSec *int64
|
|
var first, last, middle, org, note, birthday, nick, prefix, suffix, mvValue, mvLabel, fieldName, fieldValue *string
|
|
var mvProperty *int
|
|
|
|
err := rows.Scan(&rowID, &first, &last, &middle, &org, ¬e, &birthday, &nick, &prefix, &suffix, &creationDateAppleSec, &mvProperty, &mvLabel, &mvValue, &fieldName, &fieldValue)
|
|
if err != nil {
|
|
return fmt.Errorf("scanning row: %w", err)
|
|
}
|
|
|
|
// I haven't seen this, but just to avoid a panic
|
|
if rowID == nil {
|
|
continue
|
|
}
|
|
|
|
// convert timestamps
|
|
if creationDateAppleSec != nil && *creationDateAppleSec != 0 {
|
|
creationDate = imessage.CocoaSecondsToTime(*creationDateAppleSec)
|
|
}
|
|
|
|
// start of new contact
|
|
if *rowID != currentContactID {
|
|
// finish previous one and process it
|
|
if entity != nil {
|
|
finalizeEntity()
|
|
}
|
|
|
|
// advance the row ID we're working on
|
|
currentContactID = *rowID
|
|
|
|
// reset for next contact and start filling it in
|
|
// (any info from the ABPerson table)
|
|
entity = new(timeline.Entity)
|
|
entity.Metadata = make(timeline.Metadata)
|
|
fields = make(map[string]string)
|
|
|
|
// build the name from its components
|
|
// (the processor remove extra spaces)
|
|
var sb strings.Builder
|
|
if prefix != nil {
|
|
sb.WriteString(*prefix)
|
|
}
|
|
if first != nil {
|
|
sb.WriteRune(' ')
|
|
sb.WriteString(*first)
|
|
}
|
|
if middle != nil {
|
|
sb.WriteRune(' ')
|
|
sb.WriteString(*middle)
|
|
}
|
|
if last != nil {
|
|
sb.WriteRune(' ')
|
|
sb.WriteString(*last)
|
|
}
|
|
if suffix != nil {
|
|
sb.WriteRune(' ')
|
|
sb.WriteString(*suffix)
|
|
}
|
|
entity.Name = sb.String()
|
|
|
|
if birthday != nil {
|
|
birthdate, err := imessage.ParseCocoaDate(*birthday)
|
|
if err == nil {
|
|
entity.Attributes = append(entity.Attributes, timeline.Attribute{
|
|
Name: "birth_date",
|
|
Value: birthdate,
|
|
})
|
|
} else {
|
|
fimp.opt.Log.Error("parsing date", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// these I'm putting as metadata because I don't see it likely
|
|
// that items would be attributed to these properties
|
|
if note != nil {
|
|
entity.Metadata["Note"] = *note
|
|
}
|
|
if nick != nil {
|
|
entity.Metadata["Nickname"] = *nick // maybe this could be an attribute, I dunno
|
|
}
|
|
|
|
if org != nil {
|
|
entity.Attributes = append(entity.Attributes, timeline.Attribute{
|
|
Name: "organization",
|
|
Value: *org,
|
|
})
|
|
}
|
|
}
|
|
|
|
// continuation (or still first row) of same contact; add any joined data
|
|
|
|
if mvProperty != nil && mvValue != nil {
|
|
switch *mvProperty {
|
|
case propertyPhoneNumber:
|
|
entity.Attributes = append(entity.Attributes, timeline.Attribute{
|
|
Name: timeline.AttributePhoneNumber,
|
|
Value: *mvValue,
|
|
Identifying: true,
|
|
})
|
|
case propertyEmailAddress:
|
|
entity.Attributes = append(entity.Attributes, timeline.Attribute{
|
|
Name: timeline.AttributeEmail,
|
|
Value: *mvValue,
|
|
Identifying: true,
|
|
})
|
|
case propertyWebsite:
|
|
entity.Attributes = append(entity.Attributes, timeline.Attribute{
|
|
Name: "url",
|
|
Value: *mvValue,
|
|
})
|
|
}
|
|
}
|
|
|
|
// this usually contains street address; but can have other random components too like username or service
|
|
if fieldName != nil && fieldValue != nil {
|
|
switch strings.ToLower(*fieldName) {
|
|
case "street", "city", "state", "zip", "country":
|
|
fields[*fieldName] = *fieldValue
|
|
}
|
|
}
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return fmt.Errorf("scanning rows: %w", err)
|
|
}
|
|
|
|
// don't forget to process the last one too!
|
|
if entity != nil {
|
|
finalizeEntity()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
propertyPhoneNumber = 3
|
|
propertyEmailAddress = 4
|
|
propertyWebsite = 22
|
|
)
|