@tx5dr/plugin-api 2.0.0 → 2.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 +201 -0
- package/dist/__tests__/adif.test.js +67 -0
- package/dist/__tests__/adif.test.js.map +1 -1
- package/dist/__tests__/testing-utils.test.js +17 -0
- package/dist/__tests__/testing-utils.test.js.map +1 -1
- package/dist/capabilities.d.ts +2 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +2 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/compatibility.d.ts +15 -0
- package/dist/compatibility.d.ts.map +1 -0
- package/dist/compatibility.js +71 -0
- package/dist/compatibility.js.map +1 -0
- package/dist/contest-logbook-ui/assets/contest-log.css +1 -0
- package/dist/contest-logbook-ui/assets/contest-log.js +40 -0
- package/dist/contest-logbook-ui/contest-log.html +13 -0
- package/dist/context.d.ts +39 -7
- package/dist/context.d.ts.map +1 -1
- package/dist/definition.d.ts +25 -1
- package/dist/definition.d.ts.map +1 -1
- package/dist/definition.js.map +1 -1
- package/dist/ft8/StandardQSOPluginRuntime.d.ts +142 -0
- package/dist/ft8/StandardQSOPluginRuntime.d.ts.map +1 -0
- package/dist/ft8/StandardQSOPluginRuntime.js +1592 -0
- package/dist/ft8/StandardQSOPluginRuntime.js.map +1 -0
- package/dist/ft8/index.d.ts +22 -0
- package/dist/ft8/index.d.ts.map +1 -0
- package/dist/ft8/index.js +176 -0
- package/dist/ft8/index.js.map +1 -0
- package/dist/helpers.d.ts +95 -3
- package/dist/helpers.d.ts.map +1 -1
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts +377 -6
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js.map +1 -1
- package/dist/simulation.d.ts +60 -0
- package/dist/simulation.d.ts.map +1 -0
- package/dist/simulation.js +2 -0
- package/dist/simulation.js.map +1 -0
- package/dist/testing/index.d.ts +5 -1
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +67 -2
- package/dist/testing/index.js.map +1 -1
- package/dist/toolkit/contest/CabrilloBuilder.d.ts +7 -0
- package/dist/toolkit/contest/CabrilloBuilder.d.ts.map +1 -0
- package/dist/toolkit/contest/CabrilloBuilder.js +11 -0
- package/dist/toolkit/contest/CabrilloBuilder.js.map +1 -0
- package/dist/toolkit/contest/ContestCategorySchema.d.ts +11 -0
- package/dist/toolkit/contest/ContestCategorySchema.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestCategorySchema.js +6 -0
- package/dist/toolkit/contest/ContestCategorySchema.js.map +1 -0
- package/dist/toolkit/contest/ContestLogbook.d.ts +223 -0
- package/dist/toolkit/contest/ContestLogbook.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestLogbook.js +845 -0
- package/dist/toolkit/contest/ContestLogbook.js.map +1 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts +28 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js +79 -0
- package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js.map +1 -0
- package/dist/toolkit/contest/ContestQsoProjector.d.ts +12 -0
- package/dist/toolkit/contest/ContestQsoProjector.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestQsoProjector.js +13 -0
- package/dist/toolkit/contest/ContestQsoProjector.js.map +1 -0
- package/dist/toolkit/contest/ContestSessionNotifier.d.ts +9 -0
- package/dist/toolkit/contest/ContestSessionNotifier.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestSessionNotifier.js +13 -0
- package/dist/toolkit/contest/ContestSessionNotifier.js.map +1 -0
- package/dist/toolkit/contest/ContestSessionRepository.d.ts +21 -0
- package/dist/toolkit/contest/ContestSessionRepository.d.ts.map +1 -0
- package/dist/toolkit/contest/ContestSessionRepository.js +27 -0
- package/dist/toolkit/contest/ContestSessionRepository.js.map +1 -0
- package/dist/toolkit/contest/DefaultContestSession.d.ts +105 -0
- package/dist/toolkit/contest/DefaultContestSession.d.ts.map +1 -0
- package/dist/toolkit/contest/DefaultContestSession.js +387 -0
- package/dist/toolkit/contest/DefaultContestSession.js.map +1 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.d.ts +100 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.d.ts.map +1 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.js +33 -0
- package/dist/toolkit/contest/DefaultContestWorkbench.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestDefinition.d.ts +83 -0
- package/dist/toolkit/contest/FT8ContestDefinition.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestDefinition.js +167 -0
- package/dist/toolkit/contest/FT8ContestDefinition.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestModules.d.ts +178 -0
- package/dist/toolkit/contest/FT8ContestModules.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestModules.js +247 -0
- package/dist/toolkit/contest/FT8ContestModules.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestPlugin.d.ts +55 -0
- package/dist/toolkit/contest/FT8ContestPlugin.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestPlugin.js +322 -0
- package/dist/toolkit/contest/FT8ContestPlugin.js.map +1 -0
- package/dist/toolkit/contest/FT8ContestTestKit.d.ts +12 -0
- package/dist/toolkit/contest/FT8ContestTestKit.d.ts.map +1 -0
- package/dist/toolkit/contest/FT8ContestTestKit.js +60 -0
- package/dist/toolkit/contest/FT8ContestTestKit.js.map +1 -0
- package/dist/toolkit/contest/contest-toolkit.test.d.ts +2 -0
- package/dist/toolkit/contest/contest-toolkit.test.d.ts.map +1 -0
- package/dist/toolkit/contest/contest-toolkit.test.js +1196 -0
- package/dist/toolkit/contest/contest-toolkit.test.js.map +1 -0
- package/dist/toolkit/contest/index.d.ts +14 -0
- package/dist/toolkit/contest/index.d.ts.map +1 -0
- package/dist/toolkit/contest/index.js +14 -0
- package/dist/toolkit/contest/index.js.map +1 -0
- package/dist/toolkit/index.d.ts +3 -0
- package/dist/toolkit/index.d.ts.map +1 -0
- package/dist/toolkit/index.js +3 -0
- package/dist/toolkit/index.js.map +1 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts +15 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.js +25 -0
- package/dist/toolkit/parallel-qso/AuthorizationLease.js.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts +40 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.js +81 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.js.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts +2 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js +31 -0
- package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js.map +1 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.d.ts +20 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.js +20 -0
- package/dist/toolkit/parallel-qso/CompletionTracker.js.map +1 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts +19 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.js +23 -0
- package/dist/toolkit/parallel-qso/LaneFrequencyController.js.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts +134 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js +554 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts +2 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js +470 -0
- package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js.map +1 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts +20 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js +29 -0
- package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js.map +1 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.d.ts +50 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.js +2 -0
- package/dist/toolkit/parallel-qso/ProtocolLane.js.map +1 -0
- package/dist/toolkit/parallel-qso/controllers.test.d.ts +2 -0
- package/dist/toolkit/parallel-qso/controllers.test.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/controllers.test.js +36 -0
- package/dist/toolkit/parallel-qso/controllers.test.js.map +1 -0
- package/dist/toolkit/parallel-qso/index.d.ts +8 -0
- package/dist/toolkit/parallel-qso/index.d.ts.map +1 -0
- package/dist/toolkit/parallel-qso/index.js +8 -0
- package/dist/toolkit/parallel-qso/index.js.map +1 -0
- package/dist/utils/adif.d.ts +1 -1
- package/dist/utils/adif.d.ts.map +1 -1
- package/dist/utils/adif.js +91 -50
- package/dist/utils/adif.js.map +1 -1
- package/package.json +27 -5
- package/src/bridge.d.ts +3 -0
- package/tokens.css +51 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
import { getBandFromFrequency } from '@tx5dr/core';
|
|
2
|
+
import { defaultContestSession } from './DefaultContestSession.js';
|
|
3
|
+
import { defaultContestWorkbench } from './DefaultContestWorkbench.js';
|
|
4
|
+
import { formatFT8ContestSubmission, projectFT8ContestQsos, scoreFT8ContestQsos } from './FT8ContestDefinition.js';
|
|
5
|
+
import { createContestQsoEnvelopeAdapter } from './ContestQsoEnvelopeAdapter.js';
|
|
6
|
+
import { maidenheadDistanceKm } from './FT8ContestModules.js';
|
|
7
|
+
import { CONTEST_WORKBENCH_ACTIONS } from './DefaultContestWorkbench.js';
|
|
8
|
+
export const CONTEST_LOGBOOK_PERMISSIONS = [
|
|
9
|
+
'logbook:session',
|
|
10
|
+
'plugin:event-bus',
|
|
11
|
+
];
|
|
12
|
+
export const DEFAULT_CONTEST_LOGBOOK_PAGE_ID = 'contest-log';
|
|
13
|
+
export const DEFAULT_CONTEST_LOGBOOK_PANEL_ID = 'contest-log';
|
|
14
|
+
export const DEFAULT_CONTEST_LOGBOOK_ENTRY = 'contest-log.html';
|
|
15
|
+
function mergeArrayById(first, second) {
|
|
16
|
+
const result = [];
|
|
17
|
+
const positions = new Map();
|
|
18
|
+
for (const item of [...first ?? [], ...second ?? []]) {
|
|
19
|
+
const existing = positions.get(item.id);
|
|
20
|
+
if (existing === undefined) {
|
|
21
|
+
positions.set(item.id, result.length);
|
|
22
|
+
result.push(item);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
result[existing] = item;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return result.length > 0 ? result : undefined;
|
|
29
|
+
}
|
|
30
|
+
function createImportToken() {
|
|
31
|
+
const randomUUID = globalThis.crypto?.randomUUID;
|
|
32
|
+
if (randomUUID)
|
|
33
|
+
return randomUUID.call(globalThis.crypto);
|
|
34
|
+
return `contest-import-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
35
|
+
}
|
|
36
|
+
function defaultContestLogbookStateView(contest, health) {
|
|
37
|
+
return {
|
|
38
|
+
schemaVersion: 1,
|
|
39
|
+
contest: {
|
|
40
|
+
id: contest.id,
|
|
41
|
+
editionId: contest.edition?.id ?? '',
|
|
42
|
+
rulesetVersion: contest.rulesetVersion ?? '',
|
|
43
|
+
},
|
|
44
|
+
health,
|
|
45
|
+
settings: { value: {}, valid: true, issues: [] },
|
|
46
|
+
score: { claimedScore: 0, qsoPoints: 0, multiplierCount: 0 },
|
|
47
|
+
qsos: [],
|
|
48
|
+
review: { pendingCount: 0, issues: [] },
|
|
49
|
+
import: { state: 'idle' },
|
|
50
|
+
export: { formats: [] },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function resolveQsoBand(record) {
|
|
54
|
+
const band = getBandFromFrequency(record.frequency);
|
|
55
|
+
return band === 'Unknown' ? undefined : band.toUpperCase();
|
|
56
|
+
}
|
|
57
|
+
function adifField(name, value) {
|
|
58
|
+
return `<${name}:${value.length}>${value}`;
|
|
59
|
+
}
|
|
60
|
+
function formatADIFDate(value) {
|
|
61
|
+
const date = new Date(value);
|
|
62
|
+
return `${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}${String(date.getUTCDate()).padStart(2, '0')}`;
|
|
63
|
+
}
|
|
64
|
+
function formatADIFTime(value) {
|
|
65
|
+
const date = new Date(value);
|
|
66
|
+
return `${String(date.getUTCHours()).padStart(2, '0')}${String(date.getUTCMinutes()).padStart(2, '0')}${String(date.getUTCSeconds()).padStart(2, '0')}`;
|
|
67
|
+
}
|
|
68
|
+
function parseADIFFields(record) {
|
|
69
|
+
const fields = {};
|
|
70
|
+
const pattern = /<([^:>]+):(\d+)(?::[^>]*)?>/gi;
|
|
71
|
+
let match;
|
|
72
|
+
while ((match = pattern.exec(record)) !== null) {
|
|
73
|
+
const length = Number.parseInt(match[2], 10);
|
|
74
|
+
const start = match.index + match[0].length;
|
|
75
|
+
fields[match[1].trim().toLowerCase()] = record.slice(start, start + length);
|
|
76
|
+
}
|
|
77
|
+
return fields;
|
|
78
|
+
}
|
|
79
|
+
function splitADIFRecords(content) {
|
|
80
|
+
const bodyStart = content.search(/<eoh>/i);
|
|
81
|
+
const body = bodyStart >= 0 ? content.slice(bodyStart + 5) : content;
|
|
82
|
+
return body.split(/<eor>/i).map((value) => value.trim()).filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
function parseADIFDateTime(dateStr, timeStr) {
|
|
85
|
+
const date = dateStr.trim();
|
|
86
|
+
const time = timeStr.trim().padEnd(6, '0');
|
|
87
|
+
const year = Number.parseInt(date.slice(0, 4), 10);
|
|
88
|
+
const month = Number.parseInt(date.slice(4, 6), 10) - 1;
|
|
89
|
+
const day = Number.parseInt(date.slice(6, 8), 10);
|
|
90
|
+
const hour = Number.parseInt(time.slice(0, 2), 10);
|
|
91
|
+
const minute = Number.parseInt(time.slice(2, 4), 10);
|
|
92
|
+
const second = Number.parseInt(time.slice(4, 6), 10);
|
|
93
|
+
return Date.UTC(year, month, day, hour, minute, second);
|
|
94
|
+
}
|
|
95
|
+
function generateADIFFile(records, options) {
|
|
96
|
+
const lines = [`<adif_ver:3>3.1<EOR>`];
|
|
97
|
+
if (options.programId)
|
|
98
|
+
lines.push(adifField('programid', options.programId));
|
|
99
|
+
lines.push('<eoh>');
|
|
100
|
+
for (const qso of records) {
|
|
101
|
+
const fields = [];
|
|
102
|
+
fields.push(adifField('qso_date', formatADIFDate(qso.startTime)));
|
|
103
|
+
fields.push(adifField('time_on', formatADIFTime(qso.startTime)));
|
|
104
|
+
fields.push(adifField('freq', (qso.frequency / 1_000_000).toFixed(6)));
|
|
105
|
+
const band = getBandFromFrequency(qso.frequency).toUpperCase();
|
|
106
|
+
if (band && band !== 'UNKNOWN')
|
|
107
|
+
fields.push(adifField('band', band));
|
|
108
|
+
const mode = qso.mode.trim().toUpperCase();
|
|
109
|
+
if (mode)
|
|
110
|
+
fields.push(adifField('mode', mode));
|
|
111
|
+
if (qso.submode)
|
|
112
|
+
fields.push(adifField('submode', qso.submode.trim().toUpperCase()));
|
|
113
|
+
if (qso.grid)
|
|
114
|
+
fields.push(adifField('gridsquare', qso.grid.trim().toUpperCase().slice(0, 4)));
|
|
115
|
+
if (qso.contestId)
|
|
116
|
+
fields.push(adifField('contest_id', qso.contestId));
|
|
117
|
+
if (qso.contestEntry)
|
|
118
|
+
fields.push(adifField('app_tx5dr_contest_entry', JSON.stringify(qso.contestEntry)));
|
|
119
|
+
if (options.includeStationCallsign && qso.myCallsign)
|
|
120
|
+
fields.push(adifField('station_callsign', qso.myCallsign.trim().toUpperCase()));
|
|
121
|
+
if (qso.myGrid)
|
|
122
|
+
fields.push(adifField('my_gridsquare', qso.myGrid.trim().toUpperCase().slice(0, 4)));
|
|
123
|
+
if (qso.reportSent)
|
|
124
|
+
fields.push(adifField('rst_sent', qso.reportSent));
|
|
125
|
+
if (qso.reportReceived)
|
|
126
|
+
fields.push(adifField('rst_rcvd', qso.reportReceived));
|
|
127
|
+
if (qso.comment)
|
|
128
|
+
fields.push(adifField('comment', qso.comment));
|
|
129
|
+
lines.push(`${fields.join('')}<eor>`);
|
|
130
|
+
}
|
|
131
|
+
return lines.join('\r\n') + '\r\n';
|
|
132
|
+
}
|
|
133
|
+
function modeOf(record) {
|
|
134
|
+
const mode = record.mode.trim().toUpperCase();
|
|
135
|
+
const submode = record.submode?.trim().toUpperCase();
|
|
136
|
+
if (mode === 'FT8' || submode === 'FT8')
|
|
137
|
+
return 'FT8';
|
|
138
|
+
if (mode === 'FT4' || submode === 'FT4')
|
|
139
|
+
return 'FT4';
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Standard FT8/FT4 record projector that extracts the fields common contest
|
|
144
|
+
* plugins repeatedly need for logbook displays and score summaries.
|
|
145
|
+
*/
|
|
146
|
+
export function standardFT8ContestQsoAdapter(options = {}) {
|
|
147
|
+
return (record) => {
|
|
148
|
+
const band = resolveQsoBand(record);
|
|
149
|
+
const mode = modeOf(record);
|
|
150
|
+
if (!band || !mode)
|
|
151
|
+
return null;
|
|
152
|
+
const operatorGrid = options.operatorGrid?.(record)?.trim().toUpperCase().slice(0, 4)
|
|
153
|
+
?? record.myGrid?.trim().toUpperCase().slice(0, 4);
|
|
154
|
+
const receivedExchange = options.receivedExchange?.(record)?.trim().toUpperCase().slice(0, 4)
|
|
155
|
+
?? record.grid?.trim().toUpperCase().slice(0, 4);
|
|
156
|
+
const sentExchange = options.sentExchange?.(record)?.trim().toUpperCase().slice(0, 4)
|
|
157
|
+
?? record.myGrid?.trim().toUpperCase().slice(0, 4);
|
|
158
|
+
const fields = {
|
|
159
|
+
frequencyHz: Math.round(record.frequency),
|
|
160
|
+
operatorCallsign: record.myCallsign?.trim().toUpperCase(),
|
|
161
|
+
operatorGrid,
|
|
162
|
+
sentExchange,
|
|
163
|
+
receivedExchange,
|
|
164
|
+
distanceKm: operatorGrid && receivedExchange && /^[A-R]{2}[0-9]{2}$/.test(operatorGrid) && /^[A-R]{2}[0-9]{2}$/.test(receivedExchange)
|
|
165
|
+
? maidenheadDistanceKm(operatorGrid, receivedExchange)
|
|
166
|
+
: undefined,
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
id: record.id,
|
|
170
|
+
callsign: record.callsign.trim().toUpperCase(),
|
|
171
|
+
band,
|
|
172
|
+
mode,
|
|
173
|
+
time: record.startTime,
|
|
174
|
+
status: record.contestEntry?.annotations?.status === 'excluded'
|
|
175
|
+
? 'excluded'
|
|
176
|
+
: record.contestEntry?.annotations?.status === 'review'
|
|
177
|
+
? 'review'
|
|
178
|
+
: 'included',
|
|
179
|
+
fields: fields,
|
|
180
|
+
...fields,
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export function officialContestExporter(contest, options) {
|
|
185
|
+
return {
|
|
186
|
+
id: contest.submission.id,
|
|
187
|
+
label: contest.submission.id,
|
|
188
|
+
extension: contest.submission.extension,
|
|
189
|
+
mediaType: contest.submission.mediaType,
|
|
190
|
+
format(records) {
|
|
191
|
+
return formatFT8ContestSubmission(contest, records, options);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export function adifContestExporter(options = {}) {
|
|
196
|
+
return {
|
|
197
|
+
id: options.id ?? 'adif',
|
|
198
|
+
label: options.label ?? 'ADIF',
|
|
199
|
+
extension: options.extension ?? '.adi',
|
|
200
|
+
mediaType: options.mediaType ?? 'text/plain',
|
|
201
|
+
format(records) {
|
|
202
|
+
return generateADIFFile(records, {
|
|
203
|
+
programId: 'TX5DR-CONTEST-LOGBOOK',
|
|
204
|
+
includeStationCallsign: options.includeStationCallsign ?? true,
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
export function adifContestImporter(options) {
|
|
210
|
+
return {
|
|
211
|
+
id: options.id ?? 'adif',
|
|
212
|
+
label: options.label ?? 'ADIF',
|
|
213
|
+
extension: options.extension ?? '.adi',
|
|
214
|
+
mediaType: options.mediaType ?? 'text/plain',
|
|
215
|
+
accept: options.accept ?? ['.adi', '.adif', 'text/plain'],
|
|
216
|
+
preview: options.preview,
|
|
217
|
+
commit: options.commit,
|
|
218
|
+
cancel: options.cancel,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Generic ADIF parse helper for contest logbooks. Contest plugins still own
|
|
223
|
+
* their validation rules through the callbacks they pass in.
|
|
224
|
+
*/
|
|
225
|
+
export function parseContestAdifContent(content) {
|
|
226
|
+
return splitADIFRecords(content).map((record, index) => {
|
|
227
|
+
const fields = parseADIFFields(record);
|
|
228
|
+
const date = fields.qso_date ?? fields.qso_date_off;
|
|
229
|
+
const time = fields.time_on ?? fields.time_off;
|
|
230
|
+
if (!date || !time || !fields.call)
|
|
231
|
+
return null;
|
|
232
|
+
return {
|
|
233
|
+
id: fields.qso_id ?? `contest-adif-${index}`,
|
|
234
|
+
callsign: fields.call.trim().toUpperCase(),
|
|
235
|
+
grid: fields.gridsquare?.trim().toUpperCase().slice(0, 4),
|
|
236
|
+
frequency: Number.parseFloat(fields.freq ?? '0') * 1_000_000,
|
|
237
|
+
mode: fields.mode?.trim().toUpperCase() ?? 'FT8',
|
|
238
|
+
submode: fields.submode?.trim().toUpperCase(),
|
|
239
|
+
startTime: parseADIFDateTime(date, time),
|
|
240
|
+
messageHistory: [],
|
|
241
|
+
comment: fields.comment,
|
|
242
|
+
contestId: fields.contest_id,
|
|
243
|
+
myCallsign: fields.station_callsign?.trim().toUpperCase(),
|
|
244
|
+
myGrid: fields.my_gridsquare?.trim().toUpperCase().slice(0, 4),
|
|
245
|
+
};
|
|
246
|
+
}).filter((record) => record !== null);
|
|
247
|
+
}
|
|
248
|
+
export function projectContestLogbookRows(contest, records) {
|
|
249
|
+
return projectFT8ContestQsos(contest, records).map((row) => ({
|
|
250
|
+
id: String(row.qso.id ?? `${row.qso.callsign}-${row.qso.startTime}`),
|
|
251
|
+
callsign: row.qso.callsign,
|
|
252
|
+
band: row.qso.band,
|
|
253
|
+
mode: row.qso.mode,
|
|
254
|
+
time: row.qso.startTime,
|
|
255
|
+
status: row.qso.status === 'review'
|
|
256
|
+
? 'review'
|
|
257
|
+
: row.qso.status === 'excluded'
|
|
258
|
+
? 'excluded'
|
|
259
|
+
: row.qso.status === 'x-qso'
|
|
260
|
+
? 'x-qso'
|
|
261
|
+
: 'included',
|
|
262
|
+
fields: {
|
|
263
|
+
eligible: row.scoreEligible,
|
|
264
|
+
submissionEligible: row.submissionEligible,
|
|
265
|
+
dupe: row.dupe,
|
|
266
|
+
issues: row.issues,
|
|
267
|
+
},
|
|
268
|
+
sentExchange: undefined,
|
|
269
|
+
receivedExchange: undefined,
|
|
270
|
+
operatorCallsign: undefined,
|
|
271
|
+
operatorGrid: undefined,
|
|
272
|
+
distanceKm: undefined,
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
export function summarizeContestLogbookScore(contest, records) {
|
|
276
|
+
return scoreFT8ContestQsos(contest, records);
|
|
277
|
+
}
|
|
278
|
+
export function renderOfficialContestSubmission(contest, records, options) {
|
|
279
|
+
return formatFT8ContestSubmission(contest, records, options);
|
|
280
|
+
}
|
|
281
|
+
export function defaultContestLogbook(options) {
|
|
282
|
+
const { contest, adapter } = options;
|
|
283
|
+
const activeContests = new Map();
|
|
284
|
+
const messagePresentations = new Map();
|
|
285
|
+
const pageId = options.pageId
|
|
286
|
+
?? (options.ui !== false ? options.ui?.pageId : undefined)
|
|
287
|
+
?? DEFAULT_CONTEST_LOGBOOK_PAGE_ID;
|
|
288
|
+
const uiOptions = options.ui === false ? undefined : options.ui ?? {};
|
|
289
|
+
const defaultPage = uiOptions ? {
|
|
290
|
+
id: pageId,
|
|
291
|
+
title: uiOptions.title ?? 'contestLogTitle',
|
|
292
|
+
entry: uiOptions.entry ?? DEFAULT_CONTEST_LOGBOOK_ENTRY,
|
|
293
|
+
icon: uiOptions.icon,
|
|
294
|
+
accessScope: 'operator',
|
|
295
|
+
resourceBinding: 'operator',
|
|
296
|
+
} : undefined;
|
|
297
|
+
const defaultPanel = uiOptions ? {
|
|
298
|
+
id: uiOptions.panelId ?? DEFAULT_CONTEST_LOGBOOK_PANEL_ID,
|
|
299
|
+
title: uiOptions.title ?? 'contestLogTitle',
|
|
300
|
+
component: 'iframe',
|
|
301
|
+
pageId,
|
|
302
|
+
slot: 'operator-action',
|
|
303
|
+
openMode: 'page',
|
|
304
|
+
icon: uiOptions.icon ?? 'file-lines',
|
|
305
|
+
} : undefined;
|
|
306
|
+
const customPages = options.ui === false ? [] : adapter.ui?.pages ?? [];
|
|
307
|
+
const customPanels = options.ui === false ? [] : adapter.panels ?? [];
|
|
308
|
+
const pages = mergeArrayById(defaultPage ? [defaultPage] : undefined, customPages);
|
|
309
|
+
const panels = mergeArrayById(defaultPanel ? [defaultPanel] : undefined, customPanels);
|
|
310
|
+
const sessionBase = defaultContestSession({
|
|
311
|
+
create: (_contest, context) => adapter.settings.seed(_contest, context),
|
|
312
|
+
sessionKey: options.sessionKey,
|
|
313
|
+
stateKey: options.stateKey,
|
|
314
|
+
title: adapter.settings.title,
|
|
315
|
+
});
|
|
316
|
+
const sessionReady = new Map();
|
|
317
|
+
const sessionCleanups = new Map();
|
|
318
|
+
const refreshMessagePresentation = async (operatorId, context, effectiveContest, records) => {
|
|
319
|
+
if (!adapter.messagePresentation)
|
|
320
|
+
return;
|
|
321
|
+
try {
|
|
322
|
+
const snapshotRecords = records ?? (await sessionBase.access(context).snapshot()).records;
|
|
323
|
+
const projection = adapter.messagePresentation(effectiveContest, context, snapshotRecords);
|
|
324
|
+
if (projection)
|
|
325
|
+
messagePresentations.set(operatorId, projection);
|
|
326
|
+
else
|
|
327
|
+
messagePresentations.delete(operatorId);
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
context.log.debug('Contest message presentation refresh failed', {
|
|
331
|
+
error: error instanceof Error ? error.message : String(error),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
const session = {
|
|
336
|
+
id: sessionBase.id,
|
|
337
|
+
async setup(input) {
|
|
338
|
+
const effectiveContest = options.resolveContest?.(input.context) ?? input.contest;
|
|
339
|
+
activeContests.set(input.context.operator.id, effectiveContest);
|
|
340
|
+
let resolveReady;
|
|
341
|
+
let rejectReady;
|
|
342
|
+
const ready = new Promise((resolve, reject) => {
|
|
343
|
+
resolveReady = resolve;
|
|
344
|
+
rejectReady = reject;
|
|
345
|
+
});
|
|
346
|
+
sessionReady.set(input.context.operator.id, ready);
|
|
347
|
+
const initialize = async (signal) => {
|
|
348
|
+
if (signal?.aborted) {
|
|
349
|
+
resolveReady();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const cleanup = await sessionBase.setup({ ...input, contest: effectiveContest });
|
|
354
|
+
sessionCleanups.set(input.context.operator.id, cleanup ?? undefined);
|
|
355
|
+
await refreshMessagePresentation(input.context.operator.id, input.context, effectiveContest);
|
|
356
|
+
resolveReady();
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
rejectReady(error);
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
let cancelDeferred;
|
|
364
|
+
if (options.deferStartup && input.context.lifecycle) {
|
|
365
|
+
cancelDeferred = input.context.lifecycle.scheduleAfterStartup(`contest-logbook:${input.context.operator.id}`, (signal) => initialize(signal));
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
await initialize();
|
|
369
|
+
}
|
|
370
|
+
return async (context) => {
|
|
371
|
+
try {
|
|
372
|
+
cancelDeferred?.();
|
|
373
|
+
resolveReady();
|
|
374
|
+
await ready.catch(() => undefined);
|
|
375
|
+
await sessionCleanups.get(context.operator.id)?.(context);
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
sessionCleanups.delete(context.operator.id);
|
|
379
|
+
sessionReady.delete(context.operator.id);
|
|
380
|
+
activeContests.delete(context.operator.id);
|
|
381
|
+
messagePresentations.delete(context.operator.id);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
const workbench = defaultContestWorkbench({
|
|
387
|
+
pageId,
|
|
388
|
+
getState: async ({ contest: currentContest, context }) => {
|
|
389
|
+
const effectiveContest = activeContests.get(context.operator.id) ?? currentContest;
|
|
390
|
+
await sessionReady.get(context.operator.id);
|
|
391
|
+
const sessionState = sessionBase.forOperator(context.operator.id).read();
|
|
392
|
+
const snapshot = await sessionBase.access(context).snapshot();
|
|
393
|
+
await refreshMessagePresentation(context.operator.id, context, effectiveContest, snapshot.records);
|
|
394
|
+
const state = await adapter.getState(effectiveContest, sessionState, context, snapshot.records);
|
|
395
|
+
const hostHealth = sessionBase.getHealth(context.operator.id);
|
|
396
|
+
return {
|
|
397
|
+
...state,
|
|
398
|
+
health: hostHealth.state === 'healthy' ? state.health : hostHealth,
|
|
399
|
+
settings: {
|
|
400
|
+
...state.settings,
|
|
401
|
+
fields: state.settings.fields ?? Object.entries(adapter.settings.settings).map(([key, descriptor]) => ({
|
|
402
|
+
key,
|
|
403
|
+
label: descriptor.label,
|
|
404
|
+
description: descriptor.description,
|
|
405
|
+
type: descriptor.type === 'boolean' || descriptor.type === 'number' ? descriptor.type : 'string',
|
|
406
|
+
options: descriptor.options,
|
|
407
|
+
})),
|
|
408
|
+
},
|
|
409
|
+
presentation: state.presentation ?? adapter.presentation,
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
decode: adapter.decode,
|
|
413
|
+
handle: async (request, handlerContext) => {
|
|
414
|
+
const effectiveContest = activeContests.get(handlerContext.context.operator.id) ?? handlerContext.contest;
|
|
415
|
+
const result = await adapter.handle(request, effectiveContest, sessionBase.forOperator(handlerContext.context.operator.id).read(), handlerContext.request, sessionBase.access(handlerContext.context), undefined, sessionBase.forOperator(handlerContext.context.operator.id), handlerContext.context);
|
|
416
|
+
await refreshMessagePresentation(handlerContext.context.operator.id, handlerContext.context, effectiveContest);
|
|
417
|
+
return result;
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
const hooks = {
|
|
421
|
+
...adapter.hooks,
|
|
422
|
+
async onQSOComplete(record, context) {
|
|
423
|
+
await adapter.hooks?.onQSOComplete?.(record, context);
|
|
424
|
+
await refreshMessagePresentation(context.operator.id, context, activeContests.get(context.operator.id) ?? contest);
|
|
425
|
+
try {
|
|
426
|
+
await sessionBase.access(context).notify('transaction');
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (!(error instanceof Error) || error.message !== 'contest_session_not_open')
|
|
430
|
+
throw error;
|
|
431
|
+
context.log.debug('Contest logbook session is not open during QSO notification');
|
|
432
|
+
}
|
|
433
|
+
for (const page of context.ui.listActivePageSessions(pageId)) {
|
|
434
|
+
context.ui.pushToSession(page.sessionId, 'stateChanged');
|
|
435
|
+
}
|
|
436
|
+
context.ui.refreshOperatorProjection();
|
|
437
|
+
},
|
|
438
|
+
async onConfigChange(changes, context) {
|
|
439
|
+
await adapter.hooks?.onConfigChange?.(changes, context);
|
|
440
|
+
if (options.resolveContest) {
|
|
441
|
+
const effectiveContest = options.resolveContest(context);
|
|
442
|
+
activeContests.set(context.operator.id, effectiveContest);
|
|
443
|
+
await sessionBase.rebind(context.operator.id, effectiveContest, context);
|
|
444
|
+
await refreshMessagePresentation(context.operator.id, context, effectiveContest);
|
|
445
|
+
}
|
|
446
|
+
for (const page of context.ui.listActivePageSessions(pageId)) {
|
|
447
|
+
context.ui.pushToSession(page.sessionId, 'stateChanged');
|
|
448
|
+
}
|
|
449
|
+
context.ui.refreshOperatorProjection();
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
void contest;
|
|
453
|
+
return {
|
|
454
|
+
settings: adapter.settings.settings,
|
|
455
|
+
quickSettings: adapter.quickSettings ?? adapter.settings.quickSettings,
|
|
456
|
+
session,
|
|
457
|
+
workbench,
|
|
458
|
+
hooks,
|
|
459
|
+
panels,
|
|
460
|
+
ui: options.ui === false ? undefined : pages || adapter.ui || uiOptions ? {
|
|
461
|
+
dir: uiOptions?.dir ?? adapter.ui?.dir ?? 'ui',
|
|
462
|
+
pages,
|
|
463
|
+
} : undefined,
|
|
464
|
+
getMessagePresentation(operatorId) {
|
|
465
|
+
return messagePresentations.get(operatorId);
|
|
466
|
+
},
|
|
467
|
+
decorateCompletion(effect, context) {
|
|
468
|
+
const record = adapter.decorateRecord?.(effect.record, contest, context) ?? effect.record;
|
|
469
|
+
return {
|
|
470
|
+
...effect,
|
|
471
|
+
record,
|
|
472
|
+
destination: effect.destination ?? sessionBase.getDestination(context.operator.id),
|
|
473
|
+
};
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
function standardContestQso(contest, record) {
|
|
478
|
+
const band = resolveQsoBand(record);
|
|
479
|
+
const mode = modeOf(record);
|
|
480
|
+
if (!band || !mode)
|
|
481
|
+
return null;
|
|
482
|
+
const entry = record.contestEntry;
|
|
483
|
+
const sentFields = entry?.sent
|
|
484
|
+
?? { grid: record.myGrid?.trim().toUpperCase() ?? '' };
|
|
485
|
+
const receivedFields = entry?.received
|
|
486
|
+
?? { grid: record.grid?.trim().toUpperCase() ?? '' };
|
|
487
|
+
const sent = contest.exchange.decode(sentFields);
|
|
488
|
+
const received = contest.exchange.decode(receivedFields);
|
|
489
|
+
const operatorGrid = record.myGrid?.trim().toUpperCase().slice(0, 4);
|
|
490
|
+
const receivedGrid = typeof receivedFields.grid === 'string'
|
|
491
|
+
? receivedFields.grid.trim().toUpperCase().slice(0, 4)
|
|
492
|
+
: undefined;
|
|
493
|
+
const status = entry?.annotations?.status === 'excluded'
|
|
494
|
+
? 'excluded'
|
|
495
|
+
: entry?.annotations?.status === 'x-qso'
|
|
496
|
+
? 'x-qso'
|
|
497
|
+
: entry?.annotations?.status === 'review' || !sent.ok || !received.ok
|
|
498
|
+
? 'review'
|
|
499
|
+
: 'included';
|
|
500
|
+
return {
|
|
501
|
+
qsoId: record.id,
|
|
502
|
+
callsign: record.callsign.trim().toUpperCase(),
|
|
503
|
+
band,
|
|
504
|
+
mode,
|
|
505
|
+
startTime: record.startTime,
|
|
506
|
+
status,
|
|
507
|
+
sentExchange: sent.ok ? sent.value : undefined,
|
|
508
|
+
receivedExchange: received.ok ? received.value : undefined,
|
|
509
|
+
operatorCallsign: record.myCallsign?.trim().toUpperCase() ?? '',
|
|
510
|
+
operatorGrid,
|
|
511
|
+
distanceKm: operatorGrid && receivedGrid
|
|
512
|
+
? maidenheadDistanceKm(operatorGrid, receivedGrid)
|
|
513
|
+
: undefined,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function standardContestRecordRow(projected, record) {
|
|
517
|
+
const qso = projected.qso;
|
|
518
|
+
return {
|
|
519
|
+
id: record.id,
|
|
520
|
+
callsign: qso.callsign,
|
|
521
|
+
band: qso.band,
|
|
522
|
+
mode: qso.mode,
|
|
523
|
+
time: qso.startTime,
|
|
524
|
+
status: qso.status === 'review' || projected.issues.includes('review')
|
|
525
|
+
? 'review'
|
|
526
|
+
: qso.status === 'excluded'
|
|
527
|
+
? 'excluded'
|
|
528
|
+
: qso.status === 'x-qso'
|
|
529
|
+
? 'x-qso'
|
|
530
|
+
: 'included',
|
|
531
|
+
sentExchange: qso.sentExchange ? JSON.stringify(qso.sentExchange) : undefined,
|
|
532
|
+
receivedExchange: qso.receivedExchange ? JSON.stringify(qso.receivedExchange) : undefined,
|
|
533
|
+
operatorCallsign: qso.operatorCallsign,
|
|
534
|
+
operatorGrid: qso.operatorGrid,
|
|
535
|
+
distanceKm: qso.distanceKm,
|
|
536
|
+
fields: {
|
|
537
|
+
eligible: projected.scoreEligible,
|
|
538
|
+
submissionEligible: projected.submissionEligible,
|
|
539
|
+
dupe: projected.dupe,
|
|
540
|
+
issues: projected.issues,
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function gridField(exchange) {
|
|
545
|
+
if (!exchange || typeof exchange !== 'object')
|
|
546
|
+
return undefined;
|
|
547
|
+
const value = exchange.grid;
|
|
548
|
+
if (typeof value !== 'string')
|
|
549
|
+
return undefined;
|
|
550
|
+
const field = value.trim().toUpperCase().slice(0, 2);
|
|
551
|
+
return /^[A-R]{2}$/.test(field) ? field : undefined;
|
|
552
|
+
}
|
|
553
|
+
function standardContestMessagePresentation(contest, records, project, revision) {
|
|
554
|
+
const workedByBand = new Map();
|
|
555
|
+
const workedFieldsByBand = new Map();
|
|
556
|
+
for (const record of records) {
|
|
557
|
+
const qso = project(record);
|
|
558
|
+
if (!qso || qso.status === 'x-qso')
|
|
559
|
+
continue;
|
|
560
|
+
const callsign = qso.callsign.trim().toUpperCase();
|
|
561
|
+
const band = qso.band.trim().toUpperCase();
|
|
562
|
+
if (!callsign || !band)
|
|
563
|
+
continue;
|
|
564
|
+
const worked = workedByBand.get(band) ?? new Set();
|
|
565
|
+
worked.add(callsign);
|
|
566
|
+
workedByBand.set(band, worked);
|
|
567
|
+
const field = gridField(qso.receivedExchange);
|
|
568
|
+
if (field) {
|
|
569
|
+
const fields = workedFieldsByBand.get(band) ?? new Set();
|
|
570
|
+
fields.add(field);
|
|
571
|
+
workedFieldsByBand.set(band, fields);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const asRecord = (values) => Object.fromEntries([...values].map(([key, entries]) => [key, [...entries].sort()]));
|
|
575
|
+
const bandAssignments = [...workedByBand].flatMap(([band, callsigns]) => [...callsigns].map((subject) => ({
|
|
576
|
+
subject,
|
|
577
|
+
partition: band,
|
|
578
|
+
classId: 'contest-worked',
|
|
579
|
+
})));
|
|
580
|
+
return {
|
|
581
|
+
revision,
|
|
582
|
+
mode: 'replace-logbook',
|
|
583
|
+
subject: 'sender-callsign',
|
|
584
|
+
partitionBy: 'band',
|
|
585
|
+
eligiblePartitions: contest.bands.map((band) => band.toUpperCase()),
|
|
586
|
+
defaultClass: 'contest-new-call',
|
|
587
|
+
classes: {
|
|
588
|
+
'contest-new-field': {
|
|
589
|
+
badges: [{ label: 'contestNewMultiplier', tone: 'secondary' }],
|
|
590
|
+
row: { tone: 'secondary', background: 'soft', accent: true },
|
|
591
|
+
emphasisWhen: [{ firstTokenIn: ['CQ'] }, { anyTokenIn: ['RR73', 'RRR', '73'] }],
|
|
592
|
+
},
|
|
593
|
+
'contest-new-call': {
|
|
594
|
+
badges: [{ label: 'contestNewCallsign', tone: 'warning' }],
|
|
595
|
+
row: { tone: 'warning', background: 'soft', accent: true },
|
|
596
|
+
emphasisWhen: [{ firstTokenIn: ['CQ'] }, { anyTokenIn: ['RR73', 'RRR', '73'] }],
|
|
597
|
+
},
|
|
598
|
+
'contest-worked': { textDecoration: 'line-through', opacity: 'muted' },
|
|
599
|
+
},
|
|
600
|
+
assignments: bandAssignments,
|
|
601
|
+
noveltyRules: [{
|
|
602
|
+
fact: 'grid-field-2',
|
|
603
|
+
knownValuesByPartition: asRecord(workedFieldsByBand),
|
|
604
|
+
classId: 'contest-new-field',
|
|
605
|
+
}],
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
function decodeStandardContestAction(action, data) {
|
|
609
|
+
const payload = data && typeof data === 'object' ? data : {};
|
|
610
|
+
if (action === CONTEST_WORKBENCH_ACTIONS.saveSettings)
|
|
611
|
+
return { action, payload };
|
|
612
|
+
if (action === CONTEST_WORKBENCH_ACTIONS.setQsoStatus) {
|
|
613
|
+
if (typeof payload.qsoId !== 'string' || !['included', 'review', 'excluded', 'x-qso'].includes(String(payload.status))) {
|
|
614
|
+
throw new Error('contest_logbook_invalid_qso_status_request');
|
|
615
|
+
}
|
|
616
|
+
return { action, payload: { qsoId: payload.qsoId, status: payload.status } };
|
|
617
|
+
}
|
|
618
|
+
if (action === CONTEST_WORKBENCH_ACTIONS.previewImport) {
|
|
619
|
+
if (typeof payload.path !== 'string' || !payload.path.startsWith('imports/'))
|
|
620
|
+
throw new Error('contest_logbook_invalid_import_path');
|
|
621
|
+
return { action, payload: { path: payload.path, fileName: typeof payload.fileName === 'string' ? payload.fileName : 'log.adi' } };
|
|
622
|
+
}
|
|
623
|
+
if (action === CONTEST_WORKBENCH_ACTIONS.commitImport || action === 'cancel-import') {
|
|
624
|
+
if (typeof payload.token !== 'string' || !payload.token)
|
|
625
|
+
throw new Error('contest_logbook_import_token_required');
|
|
626
|
+
return { action, payload: { token: payload.token } };
|
|
627
|
+
}
|
|
628
|
+
if (action === CONTEST_WORKBENCH_ACTIONS.export) {
|
|
629
|
+
if (typeof payload.formatId !== 'string' || !payload.formatId)
|
|
630
|
+
throw new Error('contest_logbook_export_format_required');
|
|
631
|
+
return { action, payload: { formatId: payload.formatId } };
|
|
632
|
+
}
|
|
633
|
+
throw new Error(`contest_logbook_unknown_action:${action}`);
|
|
634
|
+
}
|
|
635
|
+
export function standardFT8ContestLogbook(options) {
|
|
636
|
+
const { contest } = options;
|
|
637
|
+
const project = (record, context) => (options.projectQso?.(record, context) ?? standardContestQso(contest, record));
|
|
638
|
+
const envelope = createContestQsoEnvelopeAdapter(contest);
|
|
639
|
+
const pending = new Map();
|
|
640
|
+
const settings = options.settings ?? {
|
|
641
|
+
settings: {},
|
|
642
|
+
seed: () => ({ schemaVersion: 1, revision: 0, settings: {} }),
|
|
643
|
+
validate: () => [],
|
|
644
|
+
};
|
|
645
|
+
const columns = options.presentation?.columns ?? [
|
|
646
|
+
{ key: 'callsign', label: 'Callsign' },
|
|
647
|
+
{ key: 'band', label: 'Band' },
|
|
648
|
+
{ key: 'mode', label: 'Mode' },
|
|
649
|
+
{ key: 'time', label: 'Time' },
|
|
650
|
+
{ key: 'receivedExchange', label: 'Exchange' },
|
|
651
|
+
];
|
|
652
|
+
const exporters = [
|
|
653
|
+
adifContestExporter(),
|
|
654
|
+
officialContestExporter(contest, options.submissionOptions),
|
|
655
|
+
];
|
|
656
|
+
const presentationRevisions = new Map();
|
|
657
|
+
const adapter = {
|
|
658
|
+
settings,
|
|
659
|
+
createQsoEnvelope: envelope,
|
|
660
|
+
exporters,
|
|
661
|
+
messagePresentation(currentContest, context, records) {
|
|
662
|
+
const operatorId = context.operator.id;
|
|
663
|
+
const revision = (presentationRevisions.get(operatorId) ?? 0) + 1;
|
|
664
|
+
presentationRevisions.set(operatorId, revision);
|
|
665
|
+
return standardContestMessagePresentation(currentContest, records, (record) => project(record, context), revision);
|
|
666
|
+
},
|
|
667
|
+
presentation: { ...options.presentation, columns },
|
|
668
|
+
getState(currentContest, session, context, records = []) {
|
|
669
|
+
const projected = records.flatMap((record) => {
|
|
670
|
+
const qso = project(record, context);
|
|
671
|
+
return qso ? [{ record, qso }] : [];
|
|
672
|
+
});
|
|
673
|
+
const projection = projectFT8ContestQsos(currentContest, projected.map(({ qso }) => qso));
|
|
674
|
+
const recordsById = new Map(projected.map((entry) => [
|
|
675
|
+
entry.qso.qsoId,
|
|
676
|
+
entry.record,
|
|
677
|
+
]));
|
|
678
|
+
const rows = projection.map((item) => {
|
|
679
|
+
const qso = item.qso;
|
|
680
|
+
return standardContestRecordRow(item, recordsById.get(qso.qsoId) ?? projected[0].record);
|
|
681
|
+
});
|
|
682
|
+
const score = scoreFT8ContestQsos(currentContest, projected.map(({ qso }) => qso));
|
|
683
|
+
const issues = projection.flatMap((item, index) => item.issues.map((code) => ({
|
|
684
|
+
code,
|
|
685
|
+
message: code,
|
|
686
|
+
qsoId: projected[index]?.record.id,
|
|
687
|
+
severity: code === 'review' || code === 'dupe' ? 'warning' : 'info',
|
|
688
|
+
})));
|
|
689
|
+
return {
|
|
690
|
+
schemaVersion: 1,
|
|
691
|
+
contest: {
|
|
692
|
+
id: currentContest.id,
|
|
693
|
+
editionId: currentContest.edition.id,
|
|
694
|
+
rulesetVersion: currentContest.rulesetVersion,
|
|
695
|
+
title: currentContest.presentation?.title,
|
|
696
|
+
officialUrl: currentContest.edition.source?.url,
|
|
697
|
+
startAt: currentContest.edition.startAt,
|
|
698
|
+
endAt: currentContest.edition.endAt,
|
|
699
|
+
modes: [...currentContest.modes],
|
|
700
|
+
bands: [...currentContest.bands],
|
|
701
|
+
exchangeId: currentContest.exchange.id,
|
|
702
|
+
exchangeSummary: currentContest.presentation?.exchange,
|
|
703
|
+
completionId: currentContest.completion.id,
|
|
704
|
+
ruleSummary: currentContest.presentation?.summary ?? `${currentContest.modes.join('/')} contest using ${currentContest.exchange.id} exchange`,
|
|
705
|
+
scoringSummary: currentContest.presentation?.scoring ?? `Scoring module: ${currentContest.scoring.id}`,
|
|
706
|
+
},
|
|
707
|
+
health: { state: 'healthy', readable: true, writable: true, updatedAt: Date.now() },
|
|
708
|
+
settings: {
|
|
709
|
+
value: session.settings,
|
|
710
|
+
valid: settings.validate(session, currentContest, context).length === 0,
|
|
711
|
+
issues: settings.validate(session, currentContest, context).map((issue) => issue.message),
|
|
712
|
+
fields: Object.entries(settings.settings).map(([key, descriptor]) => ({
|
|
713
|
+
key,
|
|
714
|
+
label: descriptor.label,
|
|
715
|
+
description: descriptor.description,
|
|
716
|
+
type: descriptor.type === 'number' || descriptor.type === 'boolean' ? descriptor.type : 'string',
|
|
717
|
+
options: descriptor.options,
|
|
718
|
+
})),
|
|
719
|
+
},
|
|
720
|
+
score: {
|
|
721
|
+
claimedScore: score.total,
|
|
722
|
+
qsoPoints: score.qsoPoints,
|
|
723
|
+
multiplierCount: score.multiplierCount,
|
|
724
|
+
details: {
|
|
725
|
+
moduleId: currentContest.scoring.id,
|
|
726
|
+
summary: currentContest.presentation?.scoring ?? `Scoring module: ${currentContest.scoring.id}`,
|
|
727
|
+
qsoCount: score.qsoCount,
|
|
728
|
+
multiplierCount: score.multiplierCount,
|
|
729
|
+
total: score.total,
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
qsos: rows,
|
|
733
|
+
review: { pendingCount: rows.filter((row) => row.status === 'review').length, issues },
|
|
734
|
+
import: { state: 'idle' },
|
|
735
|
+
export: { formats: exporters.map((item) => ({ id: item.id, label: item.label, extension: item.extension, enabled: item.enabled?.({ contest: currentContest, session }) ?? true })) },
|
|
736
|
+
columns,
|
|
737
|
+
presentation: options.presentation,
|
|
738
|
+
};
|
|
739
|
+
},
|
|
740
|
+
decode: decodeStandardContestAction,
|
|
741
|
+
async handle(request, currentContest, session, requestContext, application, records, state, pluginContext) {
|
|
742
|
+
if (request.action === CONTEST_WORKBENCH_ACTIONS.saveSettings) {
|
|
743
|
+
const nextSettings = request.payload;
|
|
744
|
+
const next = { ...session, settings: nextSettings };
|
|
745
|
+
const issues = settings.validate(next, currentContest, pluginContext);
|
|
746
|
+
if (issues.length > 0)
|
|
747
|
+
throw new Error(`contest_logbook_invalid_settings:${issues.map((issue) => issue.code).join(',')}`);
|
|
748
|
+
state?.update(() => next);
|
|
749
|
+
await state?.flush();
|
|
750
|
+
await application?.notify('manual');
|
|
751
|
+
return { saved: true };
|
|
752
|
+
}
|
|
753
|
+
if (!application)
|
|
754
|
+
throw new Error('contest_logbook_session_unavailable');
|
|
755
|
+
if (request.action === CONTEST_WORKBENCH_ACTIONS.setQsoStatus) {
|
|
756
|
+
const payload = request.payload;
|
|
757
|
+
await application.transact((snapshot) => {
|
|
758
|
+
const current = snapshot.records.find((record) => record.id === payload.qsoId);
|
|
759
|
+
if (!current)
|
|
760
|
+
throw new Error('contest_logbook_qso_not_found');
|
|
761
|
+
const contestEntry = current.contestEntry ? {
|
|
762
|
+
...current.contestEntry,
|
|
763
|
+
annotations: { ...current.contestEntry.annotations, status: payload.status },
|
|
764
|
+
} : undefined;
|
|
765
|
+
return [{ type: 'update', qsoId: current.id, updates: { ...(contestEntry ? { contestEntry } : {}), contestId: current.contestId ?? currentContest.id } }];
|
|
766
|
+
}, { reason: 'review' });
|
|
767
|
+
return { updated: true };
|
|
768
|
+
}
|
|
769
|
+
if (request.action === CONTEST_WORKBENCH_ACTIONS.previewImport) {
|
|
770
|
+
const payload = request.payload;
|
|
771
|
+
const source = await requestContext.files.read(payload.path);
|
|
772
|
+
await requestContext.files.delete(payload.path).catch(() => false);
|
|
773
|
+
if (!source)
|
|
774
|
+
throw new Error('contest_logbook_import_file_missing');
|
|
775
|
+
const imported = parseContestAdifContent(source.toString('utf8'));
|
|
776
|
+
const token = createImportToken();
|
|
777
|
+
pending.set(token, { operatorId: requestContext.instanceTarget.kind === 'operator' ? requestContext.instanceTarget.operatorId : '', pageSessionId: requestContext.pageSessionId, records: imported, createdAt: Date.now() });
|
|
778
|
+
const importProjection = imported.map((record) => project(record, pluginContext));
|
|
779
|
+
const importable = importProjection.filter(Boolean).length;
|
|
780
|
+
return { token, fileName: payload.fileName, preview: { totalRead: imported.length, importable, review: importProjection.filter((qso) => qso?.status === 'review').length, duplicates: 0, rejected: imported.length - importable } };
|
|
781
|
+
}
|
|
782
|
+
if (request.action === 'cancel-import') {
|
|
783
|
+
pending.delete(request.payload.token);
|
|
784
|
+
return { cancelled: true };
|
|
785
|
+
}
|
|
786
|
+
if (request.action === CONTEST_WORKBENCH_ACTIONS.commitImport) {
|
|
787
|
+
const token = request.payload.token;
|
|
788
|
+
const entry = pending.get(token);
|
|
789
|
+
if (!entry || entry.operatorId !== (requestContext.instanceTarget.kind === 'operator' ? requestContext.instanceTarget.operatorId : '') || entry.pageSessionId !== requestContext.pageSessionId || Date.now() - entry.createdAt > 15 * 60_000)
|
|
790
|
+
throw new Error('contest_logbook_import_preview_expired');
|
|
791
|
+
await application.transact((snapshot) => {
|
|
792
|
+
const existing = new Set(snapshot.records.map((record) => `${record.callsign}:${record.startTime}:${record.frequency}`));
|
|
793
|
+
return entry.records.flatMap((record) => {
|
|
794
|
+
if (!project(record, pluginContext))
|
|
795
|
+
return [];
|
|
796
|
+
const decorated = options.decorateRecord?.(record, pluginContext) ?? record;
|
|
797
|
+
const key = `${decorated.callsign}:${decorated.startTime}:${decorated.frequency}`;
|
|
798
|
+
return existing.has(key) ? [] : [{ type: 'add', record: decorated }];
|
|
799
|
+
});
|
|
800
|
+
}, { reason: 'import' });
|
|
801
|
+
pending.delete(token);
|
|
802
|
+
return { imported: entry.records.length };
|
|
803
|
+
}
|
|
804
|
+
if (request.action === CONTEST_WORKBENCH_ACTIONS.export) {
|
|
805
|
+
const formatId = request.payload.formatId;
|
|
806
|
+
const exporter = exporters.find((item) => item.id === formatId);
|
|
807
|
+
if (!exporter)
|
|
808
|
+
throw new Error('contest_logbook_export_format_unknown');
|
|
809
|
+
const all = await application.query();
|
|
810
|
+
const projected = all.flatMap((record) => { const qso = project(record, pluginContext); return qso ? [{ record, qso }] : []; });
|
|
811
|
+
const exportRecords = formatId === 'adif' ? all : projected.map(({ qso }) => qso);
|
|
812
|
+
return { fileName: `contest-${currentContest.id}.${exporter.extension.replace(/^\./, '')}`, mediaType: exporter.mediaType ?? 'text/plain', text: exporter.format(exportRecords, options.submissionOptions) };
|
|
813
|
+
}
|
|
814
|
+
throw new Error(`contest_logbook_unknown_action:${request.action}`);
|
|
815
|
+
},
|
|
816
|
+
decorateRecord(record, _currentContest, context) {
|
|
817
|
+
if (options.decorateRecord)
|
|
818
|
+
return options.decorateRecord(record, context);
|
|
819
|
+
const qso = project(record, context);
|
|
820
|
+
if (!qso?.sentExchange || !qso.receivedExchange)
|
|
821
|
+
return { ...record, contestId: contest.id };
|
|
822
|
+
return {
|
|
823
|
+
...record,
|
|
824
|
+
contestId: contest.id,
|
|
825
|
+
contestEntry: envelope.create({
|
|
826
|
+
sent: qso.sentExchange,
|
|
827
|
+
received: qso.receivedExchange,
|
|
828
|
+
annotations: { status: qso.status ?? 'included', source: 'contest-logbook' },
|
|
829
|
+
}),
|
|
830
|
+
};
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
const module = defaultContestLogbook({
|
|
834
|
+
contest,
|
|
835
|
+
pageId: options.pageId,
|
|
836
|
+
ui: options.ui,
|
|
837
|
+
resolveContest: options.resolveContest,
|
|
838
|
+
sessionKey: options.sessionKey,
|
|
839
|
+
stateKey: options.stateKey,
|
|
840
|
+
deferStartup: true,
|
|
841
|
+
adapter,
|
|
842
|
+
});
|
|
843
|
+
return module;
|
|
844
|
+
}
|
|
845
|
+
//# sourceMappingURL=ContestLogbook.js.map
|