@tikoci/centrs 0.1.2 → 0.1.3
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/CHANGELOG.md +59 -0
- package/GLOSSARY.txt +71 -0
- package/README.md +24 -8
- package/SECURITY.md +3 -3
- package/commands/api/README.md +27 -9
- package/commands/api/examples.md +109 -6
- package/commands/check/README.md +371 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/execute/README.md +39 -4
- package/commands/execute/examples.md +90 -6
- package/commands/retrieve/README.md +36 -50
- package/commands/retrieve/examples.md +40 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +32 -2
- package/commands/transfer/examples.md +66 -0
- package/docs/CONSTITUTION.md +36 -11
- package/docs/MATRIX.md +68 -172
- package/docs/errors/README.md +2 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/package.json +6 -6
- package/src/api-fanout.ts +454 -0
- package/src/api.ts +91 -19
- package/src/cli/api.ts +135 -11
- package/src/cli/btest.ts +20 -2
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +107 -6
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +135 -90
- package/src/cli/selection.ts +200 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +171 -47
- package/src/cli.ts +7 -0
- package/src/core/error-catalog.ts +55 -0
- package/src/core/fanout.ts +143 -2
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/execute-fanout.ts +438 -0
- package/src/execute.ts +120 -23
- package/src/index.ts +105 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +46 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/selection.ts +304 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +20 -2
- package/src/retrieve-fanout.ts +225 -133
- package/src/retrieve.ts +39 -7
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +527 -0
- package/src/transfer.ts +61 -11
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
* token (which may have been quoted).
|
|
18
18
|
* - Free-form text outside well-formed `key=value` tokens is inert: no value,
|
|
19
19
|
* no warning.
|
|
20
|
-
* - Allowlisted keys populate `values
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* - Allowlisted keys populate `values`; lookup keys (`identity`/`mac`/`ip`)
|
|
21
|
+
* populate `lookups`; geo facts (`lat`/`lon`/`altitude`/`altitude-type`,
|
|
22
|
+
* issue #146 Slice 1) populate `geo` — all three are recognized (no warning).
|
|
23
|
+
* First-class CDB keys are rejected with a `cdb/reserved-option` warning
|
|
24
|
+
* (they have dedicated CDB tags and must not live in the comment). Any other
|
|
25
|
+
* key emits `cdb/unknown-option`.
|
|
23
26
|
*
|
|
24
27
|
* This module is intentionally pure: it returns raw string values. Type
|
|
25
28
|
* coercion (`validate` -> boolean, `timeout`/`port` -> integer) and settings
|
|
@@ -52,6 +55,23 @@ export const commentKvLookupKeys = ["identity", "mac", "ip"] as const;
|
|
|
52
55
|
|
|
53
56
|
export type CommentKvLookupKey = (typeof commentKvLookupKeys)[number];
|
|
54
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Device GPS facts (issue #146 Slice 1): recognized comment-kv keys that are
|
|
60
|
+
* **facts, not settings** — queryable via `--where` and surfaced in `devices
|
|
61
|
+
* show`/`list`'s `location` block, but never coerced into resolver settings
|
|
62
|
+
* (distinct from both the override allowlist above and the lookup keys below).
|
|
63
|
+
* See `commands/devices/README.md` (Location / GPS) and `src/resolver/geo.ts`
|
|
64
|
+
* for parsing/validation.
|
|
65
|
+
*/
|
|
66
|
+
export const commentKvGeoKeys = [
|
|
67
|
+
"lat",
|
|
68
|
+
"lon",
|
|
69
|
+
"altitude",
|
|
70
|
+
"altitude-type",
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
export type CommentKvGeoKey = (typeof commentKvGeoKeys)[number];
|
|
74
|
+
|
|
55
75
|
/**
|
|
56
76
|
* First-class CDB fields. These have dedicated CDB tags and must never be
|
|
57
77
|
* expressed through the comment kv-soup; `devices set` refuses to write them
|
|
@@ -76,6 +96,8 @@ export interface CommentKvResult {
|
|
|
76
96
|
values: Partial<Record<CommentKvKey, string>>;
|
|
77
97
|
/** Lookup keys (identity/mac/ip), last-wins, raw string values. */
|
|
78
98
|
lookups: Partial<Record<CommentKvLookupKey, string>>;
|
|
99
|
+
/** Geo facts (lat/lon/altitude/altitude-type), last-wins, raw string values. */
|
|
100
|
+
geo: Partial<Record<CommentKvGeoKey, string>>;
|
|
79
101
|
warnings: CommentKvWarning[];
|
|
80
102
|
}
|
|
81
103
|
|
|
@@ -95,6 +117,7 @@ const BARE_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
|
95
117
|
const allowSet = new Set<string>(commentKvAllowlist);
|
|
96
118
|
const reservedSet = new Set<string>(commentKvReservedKeys);
|
|
97
119
|
const lookupSet = new Set<string>(commentKvLookupKeys);
|
|
120
|
+
const geoSet = new Set<string>(commentKvGeoKeys);
|
|
98
121
|
|
|
99
122
|
function isAllowlistKey(key: string): key is CommentKvKey {
|
|
100
123
|
return allowSet.has(key);
|
|
@@ -104,6 +127,10 @@ function isLookupKey(key: string): key is CommentKvLookupKey {
|
|
|
104
127
|
return lookupSet.has(key);
|
|
105
128
|
}
|
|
106
129
|
|
|
130
|
+
function isGeoKey(key: string): key is CommentKvGeoKey {
|
|
131
|
+
return geoSet.has(key);
|
|
132
|
+
}
|
|
133
|
+
|
|
107
134
|
/**
|
|
108
135
|
* Shell-word tokenize a comment, tracking which characters originated inside a
|
|
109
136
|
* quoted span so the kv splitter can require a bare, unquoted key.
|
|
@@ -208,6 +235,7 @@ function splitKv(word: ShellWord): { key: string; value: string } | undefined {
|
|
|
208
235
|
export function parseCommentKv(comment: string): CommentKvResult {
|
|
209
236
|
const values: Partial<Record<CommentKvKey, string>> = {};
|
|
210
237
|
const lookups: Partial<Record<CommentKvLookupKey, string>> = {};
|
|
238
|
+
const geo: Partial<Record<CommentKvGeoKey, string>> = {};
|
|
211
239
|
const warnings: CommentKvWarning[] = [];
|
|
212
240
|
|
|
213
241
|
for (const word of tokenize(comment)) {
|
|
@@ -219,6 +247,10 @@ export function parseCommentKv(comment: string): CommentKvResult {
|
|
|
219
247
|
lookups[kv.key] = kv.value;
|
|
220
248
|
continue;
|
|
221
249
|
}
|
|
250
|
+
if (isGeoKey(kv.key)) {
|
|
251
|
+
geo[kv.key] = kv.value;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
222
254
|
if (isAllowlistKey(kv.key)) {
|
|
223
255
|
values[kv.key] = kv.value;
|
|
224
256
|
continue;
|
|
@@ -238,7 +270,29 @@ export function parseCommentKv(comment: string): CommentKvResult {
|
|
|
238
270
|
});
|
|
239
271
|
}
|
|
240
272
|
|
|
241
|
-
return { values, lookups, warnings };
|
|
273
|
+
return { values, lookups, geo, warnings };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Read EVERY well-formed `key=value` token from a comment as raw facts —
|
|
278
|
+
* **no allowlist filter, no warnings, no setting coercion**. This is the
|
|
279
|
+
* device-class selector surface (`--where`): derived facts a record carries in
|
|
280
|
+
* its comment (e.g. `board=RB5009`, `version=7.23.1`) are real selectors here,
|
|
281
|
+
* even though they are NOT settings (so {@link parseCommentKv} drops them with a
|
|
282
|
+
* `cdb/unknown-option` warning). Last token wins on a duplicate bare key. Core
|
|
283
|
+
* CDB fields (`target`/`identity`/`group`/`mac`) are layered ON TOP of this map
|
|
284
|
+
* by the selector and win, so a hand-written `group=`/`target=` comment token
|
|
285
|
+
* can never spoof a first-class field.
|
|
286
|
+
*/
|
|
287
|
+
export function parseRawCommentFacts(comment: string): Record<string, string> {
|
|
288
|
+
const facts: Record<string, string> = {};
|
|
289
|
+
for (const word of tokenize(comment)) {
|
|
290
|
+
const kv = splitKv(word);
|
|
291
|
+
if (kv) {
|
|
292
|
+
facts[kv.key] = kv.value;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return facts;
|
|
242
296
|
}
|
|
243
297
|
|
|
244
298
|
/**
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `centrs.env` — the user-global config-file precedence tier (constitution:
|
|
3
|
+
* "Settings precedence", `default < config < comment-kv < env < cli`).
|
|
4
|
+
*
|
|
5
|
+
* Two read paths, deliberately kept separate:
|
|
6
|
+
*
|
|
7
|
+
* - {@link loadEnvFileDefaults} is what every *other* command calls to pick
|
|
8
|
+
* up the `config` tier. It honors `CENTRS_SKIP_ENV_FILE` (returns `{}` when
|
|
9
|
+
* set) and is safe to call unconditionally once per invocation.
|
|
10
|
+
* - {@link readSettingsFileRaw} is what the `settings` command itself calls.
|
|
11
|
+
* `settings` always inspects/edits the real file regardless of
|
|
12
|
+
* `--skip-env-file` — that flag controls whether *other* commands see the
|
|
13
|
+
* file's values, not whether `settings` can see the file.
|
|
14
|
+
*
|
|
15
|
+
* The path resolver takes an injectable `env` map (mirroring
|
|
16
|
+
* `defaultCdbPath` in `src/devices.ts`) so tests can point
|
|
17
|
+
* `XDG_CONFIG_HOME` at a per-test temp directory instead of touching a real
|
|
18
|
+
* user's `~/.config`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { parseBoolean, REFUSED_CONFIG_ENV_KEYS } from "./settings.ts";
|
|
24
|
+
|
|
25
|
+
const ENV_XDG_CONFIG_HOME = "XDG_CONFIG_HOME";
|
|
26
|
+
const ENV_HOME = "HOME";
|
|
27
|
+
const ENV_SKIP_ENV_FILE = "CENTRS_SKIP_ENV_FILE";
|
|
28
|
+
const CONFIG_RELATIVE_DIR = "tikoci";
|
|
29
|
+
const CONFIG_FILE_NAME = "centrs.env";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve `${XDG_CONFIG_HOME:-~/.config}/tikoci/centrs.env`. This is a
|
|
33
|
+
* deliberately different base-path convention from `defaultCdbPath`'s
|
|
34
|
+
* `$HOME`-only scheme — the constitution and the settings spec already
|
|
35
|
+
* commit to XDG for this file specifically.
|
|
36
|
+
*/
|
|
37
|
+
export function defaultSettingsPath(
|
|
38
|
+
env?: Record<string, string | undefined>,
|
|
39
|
+
): string {
|
|
40
|
+
const xdgConfigHome = env?.[ENV_XDG_CONFIG_HOME];
|
|
41
|
+
const configHome =
|
|
42
|
+
xdgConfigHome && xdgConfigHome.length > 0
|
|
43
|
+
? xdgConfigHome
|
|
44
|
+
: join(env?.[ENV_HOME] ?? homedir(), ".config");
|
|
45
|
+
return join(configHome, CONFIG_RELATIVE_DIR, CONFIG_FILE_NAME);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isSkippingEnvFile(env: Record<string, string | undefined>): boolean {
|
|
49
|
+
const raw = env[ENV_SKIP_ENV_FILE];
|
|
50
|
+
if (raw === undefined) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return parseBoolean(raw, ENV_SKIP_ENV_FILE);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SettingsFileContents {
|
|
61
|
+
readonly exists: boolean;
|
|
62
|
+
/** Every non-blank, non-comment line verbatim, in file order. */
|
|
63
|
+
readonly lines: readonly string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Raw line-level read, no `CENTRS_SKIP_ENV_FILE` short-circuit. Used by
|
|
68
|
+
* `settings` itself, which must always see the real file.
|
|
69
|
+
*/
|
|
70
|
+
export async function readSettingsFileRaw(
|
|
71
|
+
path: string,
|
|
72
|
+
): Promise<SettingsFileContents> {
|
|
73
|
+
const file = Bun.file(path);
|
|
74
|
+
if (!(await file.exists())) {
|
|
75
|
+
return { exists: false, lines: [] };
|
|
76
|
+
}
|
|
77
|
+
const text = await file.text();
|
|
78
|
+
return { exists: true, lines: text.split("\n") };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse `CENTRS_*=value` lines into a flat map. Blank lines and `#`
|
|
83
|
+
* comments are ignored; a later duplicate key wins. Malformed lines
|
|
84
|
+
* (no `=`, or a key that isn't a bare identifier) are silently skipped —
|
|
85
|
+
* a hand-edited file with foreign content should not fail every other
|
|
86
|
+
* command's settings resolution.
|
|
87
|
+
*/
|
|
88
|
+
export function parseEnvFileDefaults(
|
|
89
|
+
lines: readonly string[],
|
|
90
|
+
): Record<string, string> {
|
|
91
|
+
const result: Record<string, string> = {};
|
|
92
|
+
for (const rawLine of lines) {
|
|
93
|
+
const line = rawLine.trim();
|
|
94
|
+
if (line.length === 0 || line.startsWith("#")) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const separatorIndex = line.indexOf("=");
|
|
98
|
+
if (separatorIndex <= 0) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
102
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
result[key] = line.slice(separatorIndex + 1).trim();
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The `config` tier every command resolver call should pass through. Empty
|
|
112
|
+
* (`{}`) when the file is absent or `CENTRS_SKIP_ENV_FILE` is active — never
|
|
113
|
+
* throws, since a missing or foreign-content file is not an error for
|
|
114
|
+
* anything but `settings` itself.
|
|
115
|
+
*
|
|
116
|
+
* Credential/self-referential keys ({@link REFUSED_CONFIG_ENV_KEYS}) are
|
|
117
|
+
* stripped even if a hand-edited file contains them: most resolvers already
|
|
118
|
+
* never consult `config` for these, but dropping them here means the shared
|
|
119
|
+
* `config` map itself never carries a secret, rather than relying on every
|
|
120
|
+
* call site to keep excluding it. `parseEnvFileDefaults` is left unfiltered
|
|
121
|
+
* so `settings print --all` can still inspect/redact a hand-added line.
|
|
122
|
+
*/
|
|
123
|
+
export async function loadEnvFileDefaults(
|
|
124
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
125
|
+
): Promise<Record<string, string>> {
|
|
126
|
+
if (isSkippingEnvFile(env)) {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
const path = defaultSettingsPath(env);
|
|
130
|
+
const { lines } = await readSettingsFileRaw(path);
|
|
131
|
+
const parsed = parseEnvFileDefaults(lines);
|
|
132
|
+
for (const key of REFUSED_CONFIG_ENV_KEYS) {
|
|
133
|
+
delete parsed[key];
|
|
134
|
+
}
|
|
135
|
+
return parsed;
|
|
136
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-class fact extraction + selector matching, shared by the fan-out
|
|
3
|
+
* resolver ({@link ../resolver/selection.ts | expandCdbSelection}) and
|
|
4
|
+
* `devices list` (`src/devices.ts`) so **both surfaces match `--where` /
|
|
5
|
+
* `--near` / `--bbox` against identical semantics**.
|
|
6
|
+
*
|
|
7
|
+
* This is a deliberate leaf module — it imports only from `comment-kv.ts`,
|
|
8
|
+
* `mac.ts`, and `geo.ts` (all leaves). It exists because `src/devices.ts` and
|
|
9
|
+
* `src/resolver/cdb.ts` form a cycle (`cdb.ts` imports `devices.ts`), so
|
|
10
|
+
* `devices.ts` cannot import `cdb.ts`'s `identityFromComment`/`macFromComment`
|
|
11
|
+
* nor `selection.ts`'s local `entryFacts` without a circular import. That forced
|
|
12
|
+
* the `--where` fact-builder to be duplicated in both files; hosting it here
|
|
13
|
+
* removes the duplication and the "keep the two in sync" hazard.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { parseCommentKv, parseRawCommentFacts } from "./comment-kv.ts";
|
|
17
|
+
import {
|
|
18
|
+
canonicalizeGeoKey,
|
|
19
|
+
type DeviceLocation,
|
|
20
|
+
deviceLocation,
|
|
21
|
+
} from "./geo.ts";
|
|
22
|
+
import { normalizeMac } from "./mac.ts";
|
|
23
|
+
|
|
24
|
+
/** One exact-match device-class clause (`attr=value`), AND-combined by callers. */
|
|
25
|
+
export interface WhereClause {
|
|
26
|
+
key: string;
|
|
27
|
+
value: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Turn a CDB entry's `(comment, target, group)` into the device-class fact map
|
|
32
|
+
* `--where` matches against: every raw comment-kv token, with the core fields
|
|
33
|
+
* (`target`/`identity`/`group`/`mac`) layered **on top** so a hand-written
|
|
34
|
+
* comment token can never spoof a first-class field. MAC is normalized so a
|
|
35
|
+
* `mac=` lookup or a MAC `target` compares separator/case-insensitively.
|
|
36
|
+
*/
|
|
37
|
+
export function entryFacts(
|
|
38
|
+
comment: string,
|
|
39
|
+
target: string,
|
|
40
|
+
group: string,
|
|
41
|
+
): Record<string, string> {
|
|
42
|
+
const facts = parseRawCommentFacts(comment);
|
|
43
|
+
const lookups = parseCommentKv(comment).lookups;
|
|
44
|
+
facts["target"] = target;
|
|
45
|
+
facts["identity"] = lookups.identity ?? target;
|
|
46
|
+
facts["group"] = group;
|
|
47
|
+
const mac = normalizeMac(lookups.mac ?? "") ?? normalizeMac(target);
|
|
48
|
+
if (mac !== undefined) {
|
|
49
|
+
facts["mac"] = mac;
|
|
50
|
+
}
|
|
51
|
+
return facts;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True when every `--where` clause matches the entry's facts (exact,
|
|
56
|
+
* AND-combined). The clause key is run through {@link canonicalizeGeoKey} here —
|
|
57
|
+
* the single seam — so a geo alias (`--where latitude=…`/`lng=…`/`elevation=…`)
|
|
58
|
+
* matches the canonical `lat`/`lon`/`altitude` fact the write side stores, on
|
|
59
|
+
* BOTH `devices list` and every fan-out command's `--where`. A no-op for
|
|
60
|
+
* non-geo keys.
|
|
61
|
+
*/
|
|
62
|
+
export function matchesWhere(
|
|
63
|
+
facts: Record<string, string>,
|
|
64
|
+
where: readonly WhereClause[],
|
|
65
|
+
): boolean {
|
|
66
|
+
return where.every(
|
|
67
|
+
(clause) => facts[canonicalizeGeoKey(clause.key)] === clause.value,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read a CDB entry's GPS location from its comment, or `undefined` when it
|
|
73
|
+
* carries no well-formed `lat`/`lon`. The single seam `--near`/`--bbox` use to
|
|
74
|
+
* exclude geo-less devices (never an error — they simply don't match).
|
|
75
|
+
*/
|
|
76
|
+
export function entryLocation(comment: string): DeviceLocation | undefined {
|
|
77
|
+
return deviceLocation(parseRawCommentFacts(comment));
|
|
78
|
+
}
|