@chronicle.app/apple-call-history 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,8 @@ and folds them into the existing comms identity graph (iMessage/WhatsApp via
9
9
  `~/Library/Application Support/CallHistoryDB/CallHistory.storedata` (read-only) —
10
10
  the rich, per-record source (direction, number, duration, participants).
11
11
 
12
- Calls emit `source: apple-call-history`, `sourceId = <call UUID>` (`ZUNIQUE_ID`). Apple's
12
+ A call's `CallSession` is keyed by `source: apple-call-history` and `sourceId = <call UUID>`
13
+ (`ZUNIQUE_ID`), and its `CallAction` by that session (`result.sourceId`). Apple's
13
14
  store has a rolling retention (~recent calls); the **longer history that Apple
14
15
  prunes is relayed through Timing.app and lives in the `timing-app` plugin**
15
16
  (`-t calls`), which emits the same `apple-call-history` + UUID identity so the two
@@ -1,4 +1,4 @@
1
- import { Record } from '@chronicle.app/etl';
1
+ import { Record, type DatabaseSchema, type Survey, type SurveyOptions } from '@chronicle.app/etl';
2
2
  import { SqliteExtractor } from '@chronicle.app/etl-sqlite';
3
3
  import CallHistoryTransformer from './CallHistoryTransformer.js';
4
4
  /**
@@ -15,10 +15,14 @@ export declare class AppleCallHistoryExtractor extends SqliteExtractor<typeof Ap
15
15
  static default: boolean;
16
16
  static defaultTransformer: typeof CallHistoryTransformer;
17
17
  static temporality: "event";
18
+ static databases: DatabaseSchema[];
18
19
  static schema: any;
19
- /** call Z_PK → remote handle values (phone/email). */
20
+ private callFilter;
21
+ /** call Z_PK → remote handle values (phone/email), when the store has the table of them. */
20
22
  private loadParticipants;
21
23
  extract(): AsyncGenerator<Record>;
22
24
  determineCount(): Promise<number | null>;
25
+ /** The calls a run reads and when, with how long they lasted, to the second. */
26
+ survey(options?: SurveyOptions): Promise<Survey | null>;
23
27
  }
24
28
  //# sourceMappingURL=AppleCallHistoryExtractor.d.ts.map
@@ -1,7 +1,25 @@
1
- import { SqliteExtractor, safariToUnixTimestamp, timeRangeConditions, unixToSafariTimestamp, iterateRows, } from '@chronicle.app/etl-sqlite';
1
+ import { APPLE_EPOCH_OFFSET_SECONDS, SqliteExtractor, countsByMonth, monthSql, safariToUnixTimestamp, timeRangeConditions, unixToSafariTimestamp, iterateRows, } from '@chronicle.app/etl-sqlite';
2
2
  import { z } from 'zod';
3
3
  import CallHistoryTransformer from './CallHistoryTransformer.js';
4
4
  const DEFAULT_DB = `${process.env.HOME}/Library/Application Support/CallHistoryDB/CallHistory.storedata`;
5
+ /** The columns of ZCALLRECORD that extract() and survey() read. */
6
+ const CALL_COLUMNS = [
7
+ 'Z_PK',
8
+ 'ZUNIQUE_ID',
9
+ 'ZDATE',
10
+ 'ZDURATION',
11
+ 'ZORIGINATED',
12
+ 'ZADDRESS',
13
+ 'ZNAME',
14
+ 'ZPARTICIPANTGROUPUUID',
15
+ ];
16
+ /** The columns a call's other participants are read from, as `table.column`. */
17
+ const PARTICIPANT_COLUMNS = [
18
+ 'Z_2REMOTEPARTICIPANTHANDLES.Z_2REMOTEPARTICIPANTCALLS',
19
+ 'Z_2REMOTEPARTICIPANTHANDLES.Z_4REMOTEPARTICIPANTHANDLES',
20
+ 'ZHANDLE.Z_PK',
21
+ 'ZHANDLE.ZVALUE',
22
+ ];
5
23
  /**
6
24
  * Apple Call History — the primary, per-record-rich call source. Each record has
7
25
  * an explicit direction, address, name, duration, and (for groups) a participant
@@ -19,15 +37,40 @@ export class AppleCallHistoryExtractor extends SqliteExtractor {
19
37
  static default = true;
20
38
  static defaultTransformer = CallHistoryTransformer;
21
39
  static temporality = 'event';
40
+ // What extract() and survey() read, which a CallHistory.storedata must have.
41
+ // Core Data names the join table of a call's other participants, and its
42
+ // columns, by entity number, which a release can change. A store without
43
+ // the table, in the second schema, or without its columns has its calls
44
+ // extracted without who else was on a group call.
45
+ static databases = [
46
+ {
47
+ tables: {
48
+ ZCALLRECORD: CALL_COLUMNS,
49
+ Z_2REMOTEPARTICIPANTHANDLES: ['Z_2REMOTEPARTICIPANTCALLS', 'Z_4REMOTEPARTICIPANTHANDLES?'],
50
+ ZHANDLE: ['Z_PK', 'ZVALUE'],
51
+ },
52
+ },
53
+ { tables: { ZCALLRECORD: CALL_COLUMNS } },
54
+ ];
22
55
  static schema = SqliteExtractor.schema.omit({ input: true }).extend({
23
56
  input: z.string().describe('Path to CallHistory.storedata').default(DEFAULT_DB),
24
57
  });
25
- /** call Z_PK → remote handle values (phone/email). */
58
+ // Calls with an id, within since/until. ZDATE is seconds since the Apple epoch.
59
+ callFilter() {
60
+ const range = timeRangeConditions('ZDATE', this.config, {
61
+ convert: date => unixToSafariTimestamp(date.getTime() / 1000),
62
+ });
63
+ const conditions = ['ZUNIQUE_ID IS NOT NULL', ...range.conditions];
64
+ return { where: conditions.join(' AND '), values: range.values };
65
+ }
66
+ /** call Z_PK → remote handle values (phone/email), when the store has the table of them. */
26
67
  loadParticipants() {
68
+ const map = new Map();
69
+ if (!PARTICIPANT_COLUMNS.every(column => this.has(column)))
70
+ return map;
27
71
  const rows = this.db.prepare(`SELECT j.Z_2REMOTEPARTICIPANTCALLS AS callPk, h.ZVALUE AS value
28
72
  FROM Z_2REMOTEPARTICIPANTHANDLES j
29
73
  JOIN ZHANDLE h ON h.Z_PK = j.Z_4REMOTEPARTICIPANTHANDLES`).all();
30
- const map = new Map();
31
74
  for (const row of rows) {
32
75
  if (!row.value)
33
76
  continue;
@@ -41,20 +84,14 @@ export class AppleCallHistoryExtractor extends SqliteExtractor {
41
84
  if (!this.db)
42
85
  throw new Error('Database not initialized');
43
86
  const participants = this.loadParticipants();
44
- const conditions = ['ZUNIQUE_ID IS NOT NULL'];
45
- const params = [];
46
- const range = timeRangeConditions('ZDATE', this.config, {
47
- convert: date => unixToSafariTimestamp(date.getTime() / 1000),
48
- });
49
- conditions.push(...range.conditions);
50
- params.push(...range.values);
87
+ const { where, values: params } = this.callFilter();
51
88
  const limit = this.getEffectiveLimit();
52
89
  let sql = `
53
90
  SELECT Z_PK AS pk, ZUNIQUE_ID AS uuid, ZDATE AS zdate, ZDURATION AS duration,
54
91
  ZORIGINATED AS originated, ZADDRESS AS address, ZNAME AS name,
55
92
  hex(ZPARTICIPANTGROUPUUID) AS groupUuid
56
93
  FROM ZCALLRECORD
57
- WHERE ${conditions.join(' AND ')}
94
+ WHERE ${where}
58
95
  ORDER BY ZDATE DESC`;
59
96
  if (limit !== null) {
60
97
  sql += ` LIMIT ?`;
@@ -81,15 +118,42 @@ export class AppleCallHistoryExtractor extends SqliteExtractor {
81
118
  if (!this.db)
82
119
  return null;
83
120
  try {
121
+ // The calls a run reads: those with an id, within since and until.
122
+ const { where, values } = this.callFilter();
84
123
  const row = this.db
85
- .prepare(`SELECT COUNT(*) AS n FROM ZCALLRECORD WHERE ZUNIQUE_ID IS NOT NULL`)
86
- .get();
87
- return row.n;
124
+ .prepare(`SELECT COUNT(*) AS n FROM ZCALLRECORD WHERE ${where}`)
125
+ .get(...values);
126
+ const limit = this.getEffectiveLimit();
127
+ return limit === null ? row.n : Math.min(row.n, limit);
88
128
  }
89
129
  catch {
90
130
  return null;
91
131
  }
92
132
  }
133
+ /** The calls a run reads and when, with how long they lasted, to the second. */
134
+ async survey(options = {}) {
135
+ if (!this.db)
136
+ return null;
137
+ const { where, values } = this.callFilter();
138
+ const row = this.db
139
+ .prepare(`SELECT COUNT(*) AS n, MIN(ZDATE) AS first, MAX(ZDATE) AS last,
140
+ COALESCE(ROUND(SUM(ZDURATION)), 0) AS seconds FROM ZCALLRECORD WHERE ${where}`)
141
+ .get(...values);
142
+ const byMonth = options.byMonth &&
143
+ countsByMonth(this.db
144
+ .prepare(`SELECT ${monthSql(`ZDATE + ${APPLE_EPOCH_OFFSET_SECONDS}`)} AS month, COUNT(*) AS n
145
+ FROM ZCALLRECORD WHERE ${where} GROUP BY month`)
146
+ .all(...values));
147
+ return {
148
+ calls: {
149
+ count: row.n,
150
+ ...(row.first !== null && { earliest: new Date(safariToUnixTimestamp(row.first) * 1000) }),
151
+ ...(row.last !== null && { latest: new Date(safariToUnixTimestamp(row.last) * 1000) }),
152
+ ...(byMonth && { byMonth }),
153
+ measures: [{ key: 'callSeconds', label: 'calls', value: row.seconds, unit: 'seconds' }],
154
+ },
155
+ };
156
+ }
93
157
  }
94
158
  function dedupe(values) {
95
159
  return [...new Set(values.map(v => v.trim()).filter(Boolean))];
@@ -1,5 +1,5 @@
1
1
  import { ChronicleTransformer, normalizePhoneNumber } from '@chronicle.app/etl';
2
- import { buildICloudPersonSchema, lookupContact } from '@chronicle.app/icloud';
2
+ import { buildICloudPersonSchema, lookupContact } from '@chronicle.app/apple';
3
3
  // The call UUID is issued by Apple's phone/call subsystem and shared with Timing's
4
4
  // relay (see the timing-app plugin), so both key on it here and fold.
5
5
  const SOURCE = 'apple-call-history';
@@ -45,9 +45,9 @@ export default class CallHistoryTransformer extends ChronicleTransformer {
45
45
  };
46
46
  const action = {
47
47
  '@type': 'CallAction',
48
- '@key': ['@type', 'source', 'sourceId'],
48
+ // Keyed by the session it made, which has the call's ID.
49
+ '@key': ['@type', 'source', 'result.sourceId'],
49
50
  source: SOURCE,
50
- sourceId: ctx.uuid,
51
51
  startTime: secondsToISO(ctx.startSec),
52
52
  endTime: secondsToISO(ctx.startSec + ctx.durationSec),
53
53
  result: session,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chronicle.app/apple-call-history",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [
@@ -24,9 +24,9 @@
24
24
  "type": "module",
25
25
  "description": "Chronicle plugin for extracting phone & FaceTime calls from Apple Call History (with a Timing.app supplement)",
26
26
  "devDependencies": {
27
- "@chronicle.app/etl": "0.3.0",
28
- "@chronicle.app/schema": "0.3.0",
29
- "@chronicle.app/tsconfig": "0.3.0",
27
+ "@chronicle.app/etl": "0.5.0",
28
+ "@chronicle.app/schema": "0.5.0",
29
+ "@chronicle.app/tsconfig": "0.5.0",
30
30
  "@types/node": "^22.10.1",
31
31
  "typescript": "^5.7.2"
32
32
  },
@@ -41,8 +41,8 @@
41
41
  "registry": "https://registry.npmjs.org/"
42
42
  },
43
43
  "dependencies": {
44
- "@chronicle.app/etl-sqlite": "0.3.0",
45
- "@chronicle.app/icloud": "0.3.0",
44
+ "@chronicle.app/etl-sqlite": "0.5.0",
45
+ "@chronicle.app/apple": "0.5.0",
46
46
  "zod": "^3.24.1"
47
47
  },
48
48
  "peerDependencies": {
@@ -68,7 +68,50 @@
68
68
  "recordTypes": [
69
69
  "calls"
70
70
  ],
71
- "default": true
71
+ "default": true,
72
+ "locations": [
73
+ {
74
+ "path": "~/Library/Application Support/CallHistoryDB/CallHistory.storedata"
75
+ }
76
+ ],
77
+ "databases": [
78
+ {
79
+ "tables": {
80
+ "ZCALLRECORD": [
81
+ "Z_PK",
82
+ "ZUNIQUE_ID",
83
+ "ZDATE",
84
+ "ZDURATION",
85
+ "ZORIGINATED",
86
+ "ZADDRESS",
87
+ "ZNAME",
88
+ "ZPARTICIPANTGROUPUUID"
89
+ ],
90
+ "Z_2REMOTEPARTICIPANTHANDLES": [
91
+ "Z_2REMOTEPARTICIPANTCALLS",
92
+ "Z_4REMOTEPARTICIPANTHANDLES?"
93
+ ],
94
+ "ZHANDLE": [
95
+ "Z_PK",
96
+ "ZVALUE"
97
+ ]
98
+ }
99
+ },
100
+ {
101
+ "tables": {
102
+ "ZCALLRECORD": [
103
+ "Z_PK",
104
+ "ZUNIQUE_ID",
105
+ "ZDATE",
106
+ "ZDURATION",
107
+ "ZORIGINATED",
108
+ "ZADDRESS",
109
+ "ZNAME",
110
+ "ZPARTICIPANTGROUPUUID"
111
+ ]
112
+ }
113
+ }
114
+ ]
72
115
  }
73
116
  },
74
117
  "platforms": [