@avgvstvs96/pigeon 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +279 -2
- package/examples/telegram.ts +82 -0
- package/package.json +25 -4
- package/skills/pigeon/SKILL.md +25 -0
- package/src/cli/format.ts +65 -0
- package/src/cli/main.ts +134 -0
- package/src/config.ts +70 -0
- package/src/define.ts +6 -0
- package/src/handles.ts +22 -0
- package/src/index.ts +11 -0
- package/src/pigeon.ts +187 -0
- package/src/redact.ts +80 -0
- package/src/sources/imessage/contacts.ts +50 -0
- package/src/sources/imessage/index.ts +282 -0
- package/src/sources/imessage/send.ts +54 -0
- package/src/sources/imessage/typedstream.ts +54 -0
- package/src/time.ts +37 -0
- package/src/types.ts +74 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { defineSource } from "./define.ts";
|
|
5
|
+
import { pigeon, type Pigeon } from "./pigeon.ts";
|
|
6
|
+
import { redact } from "./redact.ts";
|
|
7
|
+
import { imessage } from "./sources/imessage/index.ts";
|
|
8
|
+
import type { Config } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export const configApi = { defineSource, imessage, redact };
|
|
11
|
+
|
|
12
|
+
export type ConfigApi = typeof configApi;
|
|
13
|
+
export type ConfigFactory = (api: ConfigApi) => Config | Promise<Config>;
|
|
14
|
+
|
|
15
|
+
export function configDir(): string {
|
|
16
|
+
const base = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
17
|
+
return join(base, "pigeon");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The config file pigeon will load, if there is one. */
|
|
21
|
+
export async function findConfig(): Promise<string | undefined> {
|
|
22
|
+
const explicit = process.env.PIGEON_CONFIG?.trim();
|
|
23
|
+
if (explicit) return explicit;
|
|
24
|
+
for (const name of ["config.ts", "config.js"]) {
|
|
25
|
+
const path = join(configDir(), name);
|
|
26
|
+
if (await Bun.file(path).exists()) return path;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validate(raw: unknown, path: string): Config {
|
|
31
|
+
if (!raw || typeof raw !== "object") throw new Error(`${path} must export a config object or a function returning one`);
|
|
32
|
+
const unknown = Object.keys(raw).filter((key) => key !== "sources" && key !== "filters");
|
|
33
|
+
if (unknown.length > 0) throw new Error(`${path}: unknown config keys: ${unknown.join(", ")} (expected sources, filters)`);
|
|
34
|
+
const { sources, filters } = raw as Config;
|
|
35
|
+
if (filters !== undefined && !(Array.isArray(filters) && filters.every((f) => typeof f === "function"))) {
|
|
36
|
+
throw new Error(`${path}: filters must be an array of functions`);
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
sources !== undefined &&
|
|
40
|
+
!(Array.isArray(sources) && sources.every((s) => s && typeof s.id === "string" && typeof s.messages === "function"))
|
|
41
|
+
) {
|
|
42
|
+
throw new Error(`${path}: sources must be an array of sources, each with an id and messages()`);
|
|
43
|
+
}
|
|
44
|
+
return { sources, filters };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reads `~/.config/pigeon/config.ts`. Unlike most tools, a config that fails
|
|
49
|
+
* to load is fatal: it may hold the filters that hide messages, and running
|
|
50
|
+
* without them would hand those messages out.
|
|
51
|
+
*/
|
|
52
|
+
export async function loadConfig(): Promise<Config> {
|
|
53
|
+
const path = await findConfig();
|
|
54
|
+
if (!path) return {};
|
|
55
|
+
if (!(await Bun.file(path).exists())) throw new Error(`config not found: ${path}`);
|
|
56
|
+
let raw: unknown;
|
|
57
|
+
try {
|
|
58
|
+
const mod = await import(pathToFileURL(path).href);
|
|
59
|
+
if (mod.default === undefined) throw new Error("it has no default export");
|
|
60
|
+
raw = await (typeof mod.default === "function" ? (mod.default as ConfigFactory)(configApi) : mod.default);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
throw new Error(`couldn't load ${path}: ${err instanceof Error ? err.message : err}`);
|
|
63
|
+
}
|
|
64
|
+
return validate(raw, path);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** pigeon with your config applied: the same messages, and the same hidden ones, as the CLI. */
|
|
68
|
+
export async function open(): Promise<Pigeon> {
|
|
69
|
+
return pigeon(await loadConfig());
|
|
70
|
+
}
|
package/src/define.ts
ADDED
package/src/handles.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const PHONE = /^\+?[\d\s().-]{7,}$/;
|
|
2
|
+
|
|
3
|
+
function digits(value: string): string {
|
|
4
|
+
return value.replace(/\D/g, "");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isPhone(value: string): boolean {
|
|
8
|
+
return PHONE.test(value.trim()) && digits(value).length >= 7;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One key per person, however the handle was written: phone numbers compare on
|
|
13
|
+
* their last 10 digits, so "+1 (516) 555-0100" and "5165550100" are the same.
|
|
14
|
+
*/
|
|
15
|
+
export function handleKey(value: string): string {
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
return isPhone(trimmed) ? digits(trimmed).slice(-10) : trimmed.toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sameHandle(a: string, b: string): boolean {
|
|
21
|
+
return handleKey(a) === handleKey(b);
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type { Attachment, Chat, Config, Filter, Message, Outgoing, Person, Query, Source } from "./types.ts";
|
|
2
|
+
export type { ConfigApi, ConfigFactory } from "./config.ts";
|
|
3
|
+
export type { MessageQuery, Pigeon } from "./pigeon.ts";
|
|
4
|
+
export type { RedactRule } from "./redact.ts";
|
|
5
|
+
export type { IMessageOptions } from "./sources/imessage/index.ts";
|
|
6
|
+
|
|
7
|
+
export { configDir, findConfig, loadConfig, open } from "./config.ts";
|
|
8
|
+
export { defineSource } from "./define.ts";
|
|
9
|
+
export { pigeon } from "./pigeon.ts";
|
|
10
|
+
export { REDACTED, redact } from "./redact.ts";
|
|
11
|
+
export { imessage } from "./sources/imessage/index.ts";
|
package/src/pigeon.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { isPhone, sameHandle } from "./handles.ts";
|
|
2
|
+
import { imessage } from "./sources/imessage/index.ts";
|
|
3
|
+
import type { Chat, Config, Filter, Message, Outgoing, Source } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export interface MessageQuery {
|
|
6
|
+
/** A chat id or name, from any source. */
|
|
7
|
+
chat?: string;
|
|
8
|
+
since?: Date;
|
|
9
|
+
until?: Date;
|
|
10
|
+
/** Words that must all appear, matched after filters run so hidden text is never searched. */
|
|
11
|
+
text?: string;
|
|
12
|
+
limit?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Pigeon {
|
|
16
|
+
sources: Source[];
|
|
17
|
+
/** Every chat, most recent first. */
|
|
18
|
+
chats(): Promise<Chat[]>;
|
|
19
|
+
/** Chats matching an id, a name, or part of a name. */
|
|
20
|
+
findChats(ref: string): Promise<Chat[]>;
|
|
21
|
+
/** Exactly one chat, or an error naming the candidates. */
|
|
22
|
+
chat(ref: string): Promise<Chat>;
|
|
23
|
+
/** Filtered messages, newest first. */
|
|
24
|
+
messages(query?: MessageQuery): AsyncGenerator<Message>;
|
|
25
|
+
/** Filtered messages as they arrive. */
|
|
26
|
+
watch(query?: Omit<MessageQuery, "since" | "until" | "limit">): AsyncGenerator<Message>;
|
|
27
|
+
send(ref: string, message: Outgoing): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Stream<T> = AsyncIterable<T> | Iterable<T>;
|
|
31
|
+
|
|
32
|
+
function iterate<T>(stream: Stream<T>): AsyncIterator<T> | Iterator<T> {
|
|
33
|
+
return Symbol.asyncIterator in stream ? stream[Symbol.asyncIterator]() : stream[Symbol.iterator]();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Merges streams that are each newest first into one that is newest first. */
|
|
37
|
+
async function* newestFirst(streams: Stream<Message>[]): AsyncGenerator<Message> {
|
|
38
|
+
const iterators = streams.map(iterate);
|
|
39
|
+
try {
|
|
40
|
+
const heads = await Promise.all(iterators.map((it) => it.next()));
|
|
41
|
+
while (true) {
|
|
42
|
+
let next = -1;
|
|
43
|
+
heads.forEach((head, i) => {
|
|
44
|
+
if (!head.done && (next < 0 || head.value.date > heads[next]!.value.date)) next = i;
|
|
45
|
+
});
|
|
46
|
+
if (next < 0) return;
|
|
47
|
+
yield heads[next]!.value;
|
|
48
|
+
heads[next] = await iterators[next]!.next();
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
await Promise.all(iterators.map((it) => it.return?.()));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Merges live streams in arrival order. */
|
|
56
|
+
async function* arrivals(streams: AsyncIterable<Message>[]): AsyncGenerator<Message> {
|
|
57
|
+
const iterators = streams.map((s) => s[Symbol.asyncIterator]());
|
|
58
|
+
const pull = (i: number) => iterators[i]!.next().then((result) => ({ i, result }));
|
|
59
|
+
const pending = new Map(iterators.map((_, i) => [i, pull(i)]));
|
|
60
|
+
try {
|
|
61
|
+
while (pending.size > 0) {
|
|
62
|
+
const { i, result } = await Promise.race(pending.values());
|
|
63
|
+
if (result.done) {
|
|
64
|
+
pending.delete(i);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
pending.set(i, pull(i));
|
|
68
|
+
yield result.value;
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
await Promise.all(iterators.map((it) => it.return?.()));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function matcher(text: string | undefined): (message: Message) => boolean {
|
|
76
|
+
const terms = text?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
|
|
77
|
+
return (m) => {
|
|
78
|
+
const haystack = m.text.toLowerCase();
|
|
79
|
+
return terms.every((term) => haystack.includes(term));
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isMessage(value: unknown): value is Message {
|
|
84
|
+
const m = value as Message | null;
|
|
85
|
+
return !!m && typeof m === "object" && typeof m.text === "string" && m.date instanceof Date && Array.isArray(m.attachments);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Runs every filter in order. A filter that throws or returns anything but
|
|
90
|
+
* `true` or a message drops the message: when in doubt, nothing leaks.
|
|
91
|
+
*/
|
|
92
|
+
function screener(filters: Filter[]): (message: Message) => Message | undefined {
|
|
93
|
+
const warned = new Set<number>();
|
|
94
|
+
return (message) => {
|
|
95
|
+
let current = message;
|
|
96
|
+
for (const [i, filter] of filters.entries()) {
|
|
97
|
+
let result: unknown;
|
|
98
|
+
try {
|
|
99
|
+
result = filter(current);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (!warned.has(i)) console.error(`pigeon: filter ${i + 1} threw, dropping what it fails on\n ${err}`);
|
|
102
|
+
warned.add(i);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
if (result === true) continue;
|
|
106
|
+
if (!isMessage(result)) return undefined;
|
|
107
|
+
current = result;
|
|
108
|
+
}
|
|
109
|
+
return current;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function only(found: Chat[], ref: string): Chat {
|
|
114
|
+
if (found.length === 1) return found[0]!;
|
|
115
|
+
const names = found.slice(0, 8).map((c) => ` ${c.name} (${c.id})`);
|
|
116
|
+
throw new Error(`"${ref}" matches ${found.length} chats, use an id:\n${names.join("\n")}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function pigeon({ sources = [imessage()], filters = [] }: Config = {}): Pigeon {
|
|
120
|
+
const screen = screener(filters);
|
|
121
|
+
const sourceOf = (chat: Chat) => sources.find((s) => s.id === chat.source);
|
|
122
|
+
|
|
123
|
+
async function chats(): Promise<Chat[]> {
|
|
124
|
+
const lists = await Promise.all(sources.map((s) => s.chats()));
|
|
125
|
+
return lists.flat().sort((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** By id, then exact name, then (unless `exact`) part of a name. */
|
|
129
|
+
async function lookup(ref: string, exact: boolean): Promise<Chat[]> {
|
|
130
|
+
const all = await chats();
|
|
131
|
+
const needle = ref.trim().toLowerCase();
|
|
132
|
+
const byId = all.filter((c) => sameHandle(c.id, ref));
|
|
133
|
+
if (byId.length > 0) return byId;
|
|
134
|
+
const byName = all.filter((c) => c.name.toLowerCase() === needle);
|
|
135
|
+
if (byName.length > 0 || exact) return byName;
|
|
136
|
+
return all.filter((c) => c.name.toLowerCase().includes(needle));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const findChats = (ref: string) => lookup(ref, false);
|
|
140
|
+
|
|
141
|
+
async function chat(ref: string): Promise<Chat> {
|
|
142
|
+
const found = await findChats(ref);
|
|
143
|
+
if (found.length === 0) throw new Error(`no chat matches "${ref}"`);
|
|
144
|
+
return only(found, ref);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function* messages({ chat: ref, since, until, text, limit = Infinity }: MessageQuery = {}) {
|
|
148
|
+
if (limit <= 0) return;
|
|
149
|
+
const target = ref === undefined ? undefined : await chat(ref);
|
|
150
|
+
const from = target ? [sourceOf(target)!] : sources;
|
|
151
|
+
const matches = matcher(text);
|
|
152
|
+
let count = 0;
|
|
153
|
+
for await (const message of newestFirst(from.map((s) => s.messages({ chat: target?.id, since, until })))) {
|
|
154
|
+
if (since && message.date < since) return;
|
|
155
|
+
if (until && message.date >= until) continue;
|
|
156
|
+
if (target && message.chat.id !== target.id) continue;
|
|
157
|
+
const kept = screen(message);
|
|
158
|
+
if (!kept || !matches(kept)) continue;
|
|
159
|
+
yield kept;
|
|
160
|
+
if (++count >= limit) return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function* watch({ chat: ref, text }: Omit<MessageQuery, "since" | "until" | "limit"> = {}) {
|
|
165
|
+
const target = ref === undefined ? undefined : await chat(ref);
|
|
166
|
+
const live = (target ? [sourceOf(target)!] : sources).filter((s) => s.watch);
|
|
167
|
+
if (live.length === 0) throw new Error("no source can watch for new messages");
|
|
168
|
+
const matches = matcher(text);
|
|
169
|
+
for await (const message of arrivals(live.map((s) => s.watch!({ chat: target?.id })))) {
|
|
170
|
+
if (target && message.chat.id !== target.id) continue;
|
|
171
|
+
const kept = screen(message);
|
|
172
|
+
if (kept && matches(kept)) yield kept;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Only an id or an exact name: a partial match could text the wrong people. */
|
|
177
|
+
async function send(ref: string, message: Outgoing): Promise<void> {
|
|
178
|
+
const found = await lookup(ref, true);
|
|
179
|
+
const target = found.length > 0 ? only(found, ref) : undefined;
|
|
180
|
+
const source = target ? sourceOf(target) : isPhone(ref) || ref.includes("@") ? sources.find((s) => s.send) : undefined;
|
|
181
|
+
if (!source) throw new Error(`no chat matches "${ref}"`);
|
|
182
|
+
if (!source.send) throw new Error(`${source.label ?? source.id} can't send messages`);
|
|
183
|
+
await source.send(target?.id ?? ref.trim(), message);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { sources, chats, findChats, chat, messages, watch, send };
|
|
187
|
+
}
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { sameHandle } from "./handles.ts";
|
|
2
|
+
import { dayRange, toDate, type DateInput } from "./time.ts";
|
|
3
|
+
import type { Filter, Message } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export const REDACTED = "[redacted]";
|
|
6
|
+
|
|
7
|
+
type OneOrMany<T> = T | T[];
|
|
8
|
+
|
|
9
|
+
export interface RedactRule {
|
|
10
|
+
/**
|
|
11
|
+
* Chat id. For a 1:1 iMessage chat that's the person's number or email; `pigeon chats` shows group ids.
|
|
12
|
+
* Names aren't accepted: they change, and a rule that silently stops matching would show what it hid.
|
|
13
|
+
*/
|
|
14
|
+
chat?: OneOrMany<string>;
|
|
15
|
+
/** Sender handle, or `"me"`. */
|
|
16
|
+
from?: OneOrMany<string>;
|
|
17
|
+
/** A whole day, local time: `"2026-10-06"`. */
|
|
18
|
+
on?: DateInput;
|
|
19
|
+
since?: DateInput;
|
|
20
|
+
until?: DateInput;
|
|
21
|
+
/** Hide only these strings. Without it, the whole message is hidden. */
|
|
22
|
+
match?: OneOrMany<string | RegExp>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function list<T>(value: OneOrMany<T> | undefined): T[] {
|
|
26
|
+
return value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function escape(text: string): string {
|
|
30
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Always a fresh global regex: a caller's sticky flag or leftover lastIndex would skip matches. */
|
|
34
|
+
function toPattern(match: string | RegExp): RegExp {
|
|
35
|
+
if (typeof match === "string") return new RegExp(escape(match), "gi");
|
|
36
|
+
return new RegExp(match.source, match.flags.replace(/[gy]/g, "") + "g");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Replaces every match of every pattern, merging overlaps so each hidden span reads as one marker. */
|
|
40
|
+
function hide(text: string, patterns: RegExp[]): string {
|
|
41
|
+
const spans = patterns
|
|
42
|
+
.flatMap((p) => [...text.matchAll(p)].map((m) => [m.index, m.index + m[0].length] as const))
|
|
43
|
+
.filter(([start, end]) => end > start)
|
|
44
|
+
.sort((a, b) => a[0] - b[0]);
|
|
45
|
+
let out = "";
|
|
46
|
+
let at = 0;
|
|
47
|
+
for (const [start, end] of spans) {
|
|
48
|
+
if (start >= at) out += text.slice(at, start) + REDACTED;
|
|
49
|
+
at = Math.max(at, end);
|
|
50
|
+
}
|
|
51
|
+
return out + text.slice(at);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A filter that hides messages instead of dropping them, so a reader can still
|
|
56
|
+
* tell something was there. Every field narrows the rule; `redact({})` hides everything.
|
|
57
|
+
*/
|
|
58
|
+
export function redact(rule: RedactRule): Filter {
|
|
59
|
+
const chats = list(rule.chat);
|
|
60
|
+
const senders = list(rule.from);
|
|
61
|
+
const [onStart, onEnd] = rule.on === undefined ? [] : dayRange(rule.on);
|
|
62
|
+
const since = rule.since === undefined ? onStart : toDate(rule.since);
|
|
63
|
+
const until = rule.until === undefined ? onEnd : toDate(rule.until);
|
|
64
|
+
const patterns = list(rule.match).map(toPattern);
|
|
65
|
+
|
|
66
|
+
const applies = (m: Message) =>
|
|
67
|
+
(chats.length === 0 || chats.some((c) => sameHandle(c, m.chat.id))) &&
|
|
68
|
+
(senders.length === 0 || senders.some((s) => sameHandle(s, m.from.id))) &&
|
|
69
|
+
(since === undefined || m.date >= since) &&
|
|
70
|
+
(until === undefined || m.date < until);
|
|
71
|
+
|
|
72
|
+
return (m) => {
|
|
73
|
+
if (!applies(m)) return true;
|
|
74
|
+
if (patterns.length === 0) return { ...m, text: REDACTED, attachments: [], redacted: true };
|
|
75
|
+
const text = hide(m.text, patterns);
|
|
76
|
+
const attachments = m.attachments.map((a) => (hide(a.name, patterns) === a.name ? a : { name: REDACTED }));
|
|
77
|
+
const changed = text !== m.text || attachments.some((a, i) => a !== m.attachments[i]);
|
|
78
|
+
return changed ? { ...m, text, attachments, redacted: true } : true;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { Glob } from "bun";
|
|
3
|
+
import { handleKey } from "../../handles.ts";
|
|
4
|
+
|
|
5
|
+
const NAME = `
|
|
6
|
+
COALESCE(
|
|
7
|
+
NULLIF(TRIM(COALESCE(r.ZFIRSTNAME, '') || ' ' || COALESCE(r.ZLASTNAME, '')), ''),
|
|
8
|
+
r.ZNICKNAME,
|
|
9
|
+
r.ZORGANIZATION
|
|
10
|
+
)`;
|
|
11
|
+
|
|
12
|
+
const HANDLES = `
|
|
13
|
+
SELECT p.ZFULLNUMBER AS handle, ${NAME} AS name
|
|
14
|
+
FROM ZABCDPHONENUMBER p JOIN ZABCDRECORD r ON r.Z_PK = p.ZOWNER
|
|
15
|
+
UNION ALL
|
|
16
|
+
SELECT e.ZADDRESS AS handle, ${NAME} AS name
|
|
17
|
+
FROM ZABCDEMAILADDRESS e JOIN ZABCDRECORD r ON r.Z_PK = e.ZOWNER`;
|
|
18
|
+
|
|
19
|
+
function databases(root: string): string[] {
|
|
20
|
+
try {
|
|
21
|
+
return ["AddressBook-v22.abcddb", "Sources/*/AddressBook-v22.abcddb"].flatMap((pattern) => [
|
|
22
|
+
...new Glob(pattern).scanSync({ cwd: root, absolute: true }),
|
|
23
|
+
]);
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Names from the macOS Contacts database, keyed by `handleKey`. Read directly
|
|
31
|
+
* so it works over ssh, where the Contacts framework isn't available. Every
|
|
32
|
+
* account (iCloud, Google, local) keeps its own database under Sources/.
|
|
33
|
+
*/
|
|
34
|
+
export function loadContacts(root: string): Map<string, string> {
|
|
35
|
+
const names = new Map<string, string>();
|
|
36
|
+
for (const file of databases(root)) {
|
|
37
|
+
let db: Database | undefined;
|
|
38
|
+
try {
|
|
39
|
+
db = new Database(file, { readonly: true });
|
|
40
|
+
for (const { handle, name } of db.query<{ handle: string | null; name: string | null }, []>(HANDLES).all()) {
|
|
41
|
+
if (handle && name && !names.has(handleKey(handle))) names.set(handleKey(handle), name);
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
} finally {
|
|
46
|
+
db?.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return names;
|
|
50
|
+
}
|