Skip to content

Commit b8fa528

Browse files
committed
add in history methods for grid3 and snap
1 parent 458ac26 commit b8fa528

11 files changed

Lines changed: 428 additions & 6 deletions

src/analytics/history.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { dotNetTicksToDate } from '../utils/dotnetTicks';
2+
import {
3+
findGrid3Users,
4+
Grid3UserPath,
5+
readAllGrid3History as readAllGrid3HistoryImpl,
6+
readGrid3History as readGrid3HistoryImpl,
7+
readGrid3HistoryForUser as readGrid3HistoryForUserImpl,
8+
} from '../processors/gridset/helpers';
9+
import {
10+
findSnapUsers,
11+
readSnapUsage as readSnapUsageImpl,
12+
readSnapUsageForUser as readSnapUsageForUserImpl,
13+
SnapUserInfo,
14+
} from '../processors/snap/helpers';
15+
16+
export type HistorySource = 'Grid' | 'Snap';
17+
18+
export interface HistoryOccurrence {
19+
timestamp: Date;
20+
latitude?: number | null;
21+
longitude?: number | null;
22+
modeling?: boolean;
23+
accessMethod?: number | null;
24+
pageId?: string | null;
25+
}
26+
27+
export interface HistoryPlatformExtras {
28+
label?: string;
29+
message?: string;
30+
buttonId?: string;
31+
contentXml?: string;
32+
}
33+
34+
export interface HistoryEntry {
35+
id: string;
36+
source: HistorySource;
37+
content: string;
38+
occurrences: HistoryOccurrence[];
39+
raw?: unknown;
40+
platform?: HistoryPlatformExtras;
41+
}
42+
43+
export { dotNetTicksToDate };
44+
45+
export function readGrid3History(historyDbPath: string): HistoryEntry[] {
46+
return readGrid3HistoryImpl(historyDbPath).map((e) => ({ ...e, source: 'Grid' }));
47+
}
48+
49+
export function readGrid3HistoryForUser(userName: string, langCode?: string): HistoryEntry[] {
50+
return readGrid3HistoryForUserImpl(userName, langCode).map((e) => ({ ...e, source: 'Grid' }));
51+
}
52+
53+
export function readAllGrid3History(): HistoryEntry[] {
54+
return readAllGrid3HistoryImpl().map((e) => ({ ...e, source: 'Grid' }));
55+
}
56+
57+
export function readSnapUsage(pagesetPath: string): HistoryEntry[] {
58+
return readSnapUsageImpl(pagesetPath).map((e) => ({ ...e, source: 'Snap' }));
59+
}
60+
61+
export function readSnapUsageForUser(
62+
userId?: string,
63+
packageNamePattern = 'TobiiDynavox'
64+
): HistoryEntry[] {
65+
return readSnapUsageForUserImpl(userId, packageNamePattern).map((e) => ({
66+
...e,
67+
source: 'Snap',
68+
}));
69+
}
70+
71+
export function listSnapUsers(): SnapUserInfo[] {
72+
return findSnapUsers();
73+
}
74+
75+
export function listGrid3Users(): Grid3UserPath[] {
76+
return findGrid3Users();
77+
}
78+
79+
/**
80+
* Convenience helper to gather all available history across Grid 3 and Snap.
81+
* Returns an empty array if no history files are present.
82+
*/
83+
export function collectUnifiedHistory(): HistoryEntry[] {
84+
const gridHistory = readAllGrid3History();
85+
const snapHistory = findSnapUsers().flatMap((u) => readSnapUsageForUser(u.userId));
86+
return [...gridHistory, ...snapHistory];
87+
}

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ export * from './core/treeStructure';
33
export * from './core/baseProcessor';
44
export * from './core/stringCasing';
55
export * from './processors';
6+
export {
7+
collectUnifiedHistory,
8+
listGrid3Users as listHistoryGrid3Users,
9+
listSnapUsers as listHistorySnapUsers,
10+
} from './analytics/history';
611

712
import { BaseProcessor } from './core/baseProcessor';
813
import { DotProcessor } from './processors/dotProcessor';

src/processors/gridset/helpers.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { AACTree, AACPage, AACButton } from '../../core/treeStructure';
44
import * as fs from 'fs';
55
import * as path from 'path';
66
import { execSync } from 'child_process';
7+
import Database from 'better-sqlite3';
8+
import { dotNetTicksToDate } from '../../utils/dotnetTicks';
79

810
function normalizeZipPath(p: string): string {
911
const unified = p.replace(/\\/g, '/');
@@ -144,6 +146,17 @@ export interface Grid3VocabularyPath {
144146
gridsetPath: string;
145147
}
146148

149+
export interface Grid3HistoryEntry {
150+
id: string;
151+
content: string;
152+
occurrences: Array<{
153+
timestamp: Date;
154+
latitude?: number | null;
155+
longitude?: number | null;
156+
}>;
157+
rawXml?: string;
158+
}
159+
147160
/**
148161
* Get the Windows Common Documents folder path from registry
149162
* Falls back to default path if registry access fails
@@ -314,3 +327,81 @@ export function findGrid3UserHistory(userName: string, langCode?: string): strin
314327

315328
return match?.historyDbPath ?? null;
316329
}
330+
331+
function parseGrid3ContentXml(xmlContent: string): string {
332+
const regex = /<r>(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?<\/r>/gis;
333+
const parts: string[] = [];
334+
let match: RegExpExecArray | null;
335+
while ((match = regex.exec(xmlContent)) !== null) {
336+
parts.push(match[1]);
337+
}
338+
if (parts.length > 0) {
339+
return parts.join('');
340+
}
341+
return xmlContent.replace(/<[^>]+>/g, '').trim();
342+
}
343+
344+
export function readGrid3History(historyDbPath: string): Grid3HistoryEntry[] {
345+
if (!fs.existsSync(historyDbPath)) return [];
346+
347+
const db = new Database(historyDbPath, { readonly: true });
348+
const rows = db
349+
.prepare(
350+
`
351+
SELECT p.Id as PhraseId,
352+
p.Text as TextValue,
353+
p.Content as ContentXml,
354+
ph.Timestamp as TickValue,
355+
ph.Latitude as Latitude,
356+
ph.Longitude as Longitude
357+
FROM PhraseHistory ph
358+
INNER JOIN Phrases p ON p.Id = ph.PhraseId
359+
WHERE ph.Timestamp <> 0
360+
ORDER BY ph.Timestamp ASC
361+
`
362+
)
363+
.all() as Array<{
364+
PhraseId: number;
365+
TextValue?: string;
366+
ContentXml?: string;
367+
TickValue?: number | bigint;
368+
Latitude?: number;
369+
Longitude?: number;
370+
}>;
371+
372+
const events = new Map<number, Grid3HistoryEntry>();
373+
374+
for (const row of rows) {
375+
const phraseId: number = row.PhraseId;
376+
const contentText = parseGrid3ContentXml(String(row.ContentXml ?? row.TextValue ?? ''));
377+
const entry =
378+
events.get(phraseId) ??
379+
({
380+
id: `grid:${phraseId}`,
381+
content: contentText,
382+
occurrences: [],
383+
rawXml: row.ContentXml,
384+
} as Grid3HistoryEntry);
385+
386+
entry.occurrences.push({
387+
timestamp: dotNetTicksToDate(BigInt(row.TickValue ?? 0)),
388+
latitude: row.Latitude ?? null,
389+
longitude: row.Longitude ?? null,
390+
});
391+
392+
events.set(phraseId, entry);
393+
}
394+
395+
return Array.from(events.values());
396+
}
397+
398+
export function readGrid3HistoryForUser(userName: string, langCode?: string): Grid3HistoryEntry[] {
399+
const dbPath = findGrid3UserHistory(userName, langCode);
400+
if (!dbPath) return [];
401+
return readGrid3History(dbPath);
402+
}
403+
404+
export function readAllGrid3History(): Grid3HistoryEntry[] {
405+
const paths = findGrid3HistoryDatabases();
406+
return paths.flatMap((p) => readGrid3History(p));
407+
}

src/processors/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,12 @@ export {
2222
findGrid3Users,
2323
findGrid3Vocabularies,
2424
findGrid3UserHistory,
25+
readGrid3History,
26+
readGrid3HistoryForUser,
27+
readAllGrid3History,
2528
type Grid3UserPath,
2629
type Grid3VocabularyPath,
30+
type Grid3HistoryEntry,
2731
} from './gridset/helpers';
2832
export {
2933
getPageTokenImageMap as getGridsetPageTokenImageMap,
@@ -38,6 +42,9 @@ export {
3842
findGrid3Users as findGridsetUsers,
3943
findGrid3Vocabularies as findGridsetVocabularies,
4044
findGrid3UserHistory as findGridsetUserHistory,
45+
readGrid3History as readGridsetHistory,
46+
readGrid3HistoryForUser as readGridsetHistoryForUser,
47+
readAllGrid3History as readAllGridsetHistory,
4148
} from './gridset/helpers';
4249
export { resolveGrid3CellImage } from './gridset/resolver';
4350

@@ -85,8 +92,11 @@ export {
8592
findSnapUsers,
8693
findSnapUserVocabularies,
8794
findSnapUserHistory,
95+
readSnapUsage,
96+
readSnapUsageForUser,
8897
type SnapPackagePath,
8998
type SnapUserInfo,
99+
type SnapUsageEntry,
90100
} from './snap/helpers';
91101

92102
// TouchChat helpers (stubs)

src/processors/snap/helpers.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { AACTree } from '../../core/treeStructure';
22
import * as fs from 'fs';
33
import * as path from 'path';
4+
import Database from 'better-sqlite3';
5+
import { dotNetTicksToDate } from '../../utils/dotnetTicks';
46

57
// Minimal Snap helpers (stubs) to align with processors/<engine>/helpers pattern
68
// NOTE: Snap buttons currently do not populate resolvedImageEntry; these helpers
@@ -73,6 +75,21 @@ export interface SnapUserInfo {
7375
vocabPaths: string[];
7476
}
7577

78+
export interface SnapUsageEntry {
79+
id: string;
80+
content: string;
81+
occurrences: Array<{
82+
timestamp: Date;
83+
modeling?: boolean;
84+
accessMethod?: number | null;
85+
}>;
86+
platform?: {
87+
label?: string;
88+
message?: string;
89+
buttonId?: string;
90+
};
91+
}
92+
7693
/**
7794
* Find Tobii Communicator Snap package paths
7895
* Searches in %LOCALAPPDATA%\Packages for Snap-related packages
@@ -212,3 +229,88 @@ export function findSnapUserHistory(userId: string, packageNamePattern = 'TobiiD
212229
2
213230
);
214231
}
232+
233+
/**
234+
* Read Snap usage history from a pageset file (.sps/.spb)
235+
*/
236+
export function readSnapUsage(pagesetPath: string): SnapUsageEntry[] {
237+
if (!fs.existsSync(pagesetPath)) return [];
238+
239+
const db = new Database(pagesetPath, { readonly: true });
240+
241+
const tableCheck = db
242+
.prepare(
243+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('ButtonUsage','Button')"
244+
)
245+
.all();
246+
if (tableCheck.length < 2) return [];
247+
248+
const rows = db
249+
.prepare(
250+
`
251+
SELECT
252+
bu.ButtonUniqueId as ButtonId,
253+
bu.Timestamp as TickValue,
254+
bu.Modeling as Modeling,
255+
bu.AccessMethod as AccessMethod,
256+
b.Label as Label,
257+
b.Message as Message
258+
FROM ButtonUsage bu
259+
LEFT JOIN Button b ON bu.ButtonUniqueId = b.UniqueId
260+
WHERE bu.Timestamp IS NOT NULL
261+
ORDER BY bu.Timestamp ASC
262+
`
263+
)
264+
.all() as Array<{
265+
ButtonId?: string;
266+
TickValue?: number | bigint;
267+
Modeling?: number;
268+
AccessMethod?: number;
269+
Label?: string;
270+
Message?: string;
271+
}>;
272+
273+
const events = new Map<string, SnapUsageEntry>();
274+
275+
for (const row of rows) {
276+
const buttonId: string = row.ButtonId ?? 'unknown';
277+
const label = row.Label ?? undefined;
278+
const message = row.Message ?? undefined;
279+
const content = message || label || '';
280+
281+
const entry =
282+
events.get(buttonId) ??
283+
({
284+
id: `snap:${buttonId}`,
285+
content,
286+
occurrences: [],
287+
platform: {
288+
label,
289+
message,
290+
buttonId,
291+
},
292+
} as SnapUsageEntry);
293+
294+
entry.occurrences.push({
295+
timestamp: dotNetTicksToDate(BigInt(row.TickValue ?? 0)),
296+
modeling: row.Modeling === 1,
297+
accessMethod: row.AccessMethod ?? null,
298+
});
299+
300+
events.set(buttonId, entry);
301+
}
302+
303+
return Array.from(events.values());
304+
}
305+
306+
/**
307+
* Read Snap usage history for a user (all pagesets)
308+
*/
309+
export function readSnapUsageForUser(
310+
userId?: string,
311+
packageNamePattern = 'TobiiDynavox'
312+
): SnapUsageEntry[] {
313+
const users = findSnapUsers(packageNamePattern).filter((u) => !userId || u.userId === userId);
314+
const pagesets = users.flatMap((u) => u.vocabPaths);
315+
return pagesets.flatMap((p) => readSnapUsage(p));
316+
}

src/utils/dotnetTicks.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const DOTNET_EPOCH_TICKS = 621355968000000000n;
2+
export const TICKS_PER_MILLISECOND = 10000n;
3+
4+
export function dotNetTicksToDate(ticks: number | bigint): Date {
5+
const tickValue = BigInt(ticks);
6+
const ms = Number((tickValue - DOTNET_EPOCH_TICKS) / TICKS_PER_MILLISECOND);
7+
return new Date(ms);
8+
}

0 commit comments

Comments
 (0)