@innspel/core 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,12 +83,13 @@ Bruk hva du vil — MMKV, AsyncStorage, SQLite, et objekt i minnet.
83
83
  ## API
84
84
 
85
85
  ```ts
86
- innspel.capture({ type, text, context?, correlation?, relatedKnownIssueId?, clientRef? })
86
+ innspel.capture({ type, text, attachment?, context?, correlation?, relatedKnownIssueId?, clientRef? })
87
87
  innspel.knownIssues.for({ screen?, version?, platform? }) // kortene som vises FØR feltet
88
88
  innspel.knownIssues.confirm(id) // «Ja, jeg også» — Reach +1, ingen ny sak
89
89
  innspel.subjects.submissions() // «Mine innspill», inkl. det som venter på nett
90
90
  innspel.subjects.status(submissionId) // status for én egen innsending
91
91
  innspel.programs.list() // invitasjoner, deltakelser, oppfølginger
92
+ innspel.attachments.upload(submissionId, attachment) // skjermbilde til en innsending som alt er lagret
92
93
  innspel.queue.pending() // det som ligger i offline-køen
93
94
  innspel.queue.flush() // sendes ellers automatisk
94
95
  innspel.scanText(text) // mønstervarsel, se under
@@ -109,6 +110,36 @@ er utløpt for lengst.
109
110
  Vis køen for brukeren. `queue.pending()` gir deg radene, og «Venter på nett —
110
111
  sendes automatisk» er teksten widgeten bruker.
111
112
 
113
+ ### Vedlegg
114
+
115
+ Ett skjermbilde per innsending, **maskert på klienten før det forlater enheten**
116
+ (K4). Kjernen har ingen fangst og ingen maskering selv — `@innspel/react-native`
117
+ har begge; her tar du imot bildet som Blob, ArrayBuffer eller Uint8Array:
118
+
119
+ ```ts
120
+ const res = await innspel.capture({
121
+ type: 'feil',
122
+ text: 'Dørlista er tom etter kl. 22.',
123
+ attachment: { mime: 'image/jpeg', body: blob, width: 1170, height: 2532 },
124
+ });
125
+
126
+ if (res.status === 'sent' && res.attachment?.status === 'failed') {
127
+ // Teksten ER lagret. Si «Sendt — skjermbildet kunne ikke legges ved» (DD-42).
128
+ }
129
+ ```
130
+
131
+ Teksten sendes først, bildet etterpå i egen forespørsel direkte til lagringen, og
132
+ bildet kan feile alene — innsendingen står uansett. Uten nett køes teksten og
133
+ bildet forkastes (`attachment.status === 'dropped'`); et skjermbilde køes aldri.
134
+
135
+ Grensene sjekkes før noe går på nettet og kaster med feltnavn: PNG, JPEG eller
136
+ WebP (aldri SVG eller GIF), høyst 5 MB og 4096 × 4096 (K9). Plattformen sjekker
137
+ dem igjen, re-enkoder bildet i en isolert prosess og fjerner EXIF.
138
+
139
+ Geometrien bak maskeringen — fingerstrøk og trykk til rektangler i bildets
140
+ piksler — ligger her (`strokTilRekter`, `trykkTilRekt`, `skaler`, `klipp`), så
141
+ `@innspel/react-native` og `@innspel/web` maskerer likt.
142
+
112
143
  ### Mønstervarsel
113
144
 
114
145
  ```ts
package/dist/index.cjs CHANGED
@@ -780,6 +780,28 @@ var createClient = (config = {}) => {
780
780
  var client = createClient(createConfig());
781
781
 
782
782
  // src/generated/api/sdk.gen.ts
783
+ var attachmentsPresign = (options) => (options.client ?? client).post({
784
+ security: [{ name: "X-Innspel-Tenant-Key", type: "apiKey" }, {
785
+ key: "FeedbackTokenAuth",
786
+ scheme: "bearer",
787
+ type: "http"
788
+ }],
789
+ url: "/v1/ingest/attachments/presign",
790
+ ...options,
791
+ headers: {
792
+ "Content-Type": "application/json",
793
+ ...options.headers
794
+ }
795
+ });
796
+ var attachmentsComplete = (options) => (options.client ?? client).post({
797
+ security: [{ name: "X-Innspel-Tenant-Key", type: "apiKey" }, {
798
+ key: "FeedbackTokenAuth",
799
+ scheme: "bearer",
800
+ type: "http"
801
+ }],
802
+ url: "/v1/ingest/attachments/{id}/complete",
803
+ ...options
804
+ });
783
805
  var knownIssuesList2 = (options) => (options?.client ?? client).get({
784
806
  security: [{ name: "X-Innspel-Tenant-Key", type: "apiKey" }, {
785
807
  key: "FeedbackTokenAuth",
@@ -1021,8 +1043,8 @@ function validerKontekst(kontekst, sti, krevPlatform) {
1021
1043
  function sl\u00E5SammenKontekst(base, p\u00E5Lagt, sdkVersion) {
1022
1044
  const ut = {};
1023
1045
  for (const felt of KONTEKST_FELTER) {
1024
- const verdi = p\u00E5Lagt?.[felt] ?? base[felt];
1025
- if (verdi !== void 0) ut[felt] = verdi;
1046
+ const verdi = p\u00E5Lagt && felt in p\u00E5Lagt ? p\u00E5Lagt[felt] : base[felt];
1047
+ if (verdi !== void 0 && verdi !== null) ut[felt] = verdi;
1026
1048
  }
1027
1049
  ut["sdkVersion"] = sdkVersion;
1028
1050
  return ut;
@@ -1129,8 +1151,66 @@ function nyClientRef() {
1129
1151
  return hex.join("");
1130
1152
  }
1131
1153
 
1154
+ // src/vedlegg.ts
1155
+ var VEDLEGG_GRENSER = {
1156
+ maxBytes: 5242880,
1157
+ maxDimension: 4096
1158
+ };
1159
+ var TILLATTE_MIME = ["image/png", "image/jpeg", "image/webp"];
1160
+ function byteLengde(body) {
1161
+ if (typeof body === "object" && body !== null && "size" in body && typeof body.size === "number") {
1162
+ return body.size;
1163
+ }
1164
+ return body.byteLength;
1165
+ }
1166
+ function validerVedlegg(input) {
1167
+ if (input === null || typeof input !== "object") {
1168
+ throw new InnspelConfigError("attachment", "m\xE5 v\xE6re et objekt med { submissionId?, mime, body, width, height }");
1169
+ }
1170
+ if (!TILLATTE_MIME.includes(input.mime)) {
1171
+ throw new InnspelConfigError(
1172
+ "attachment.mime",
1173
+ `m\xE5 v\xE6re en av ${TILLATTE_MIME.join(", ")}. SVG og GIF er aldri tillatt (K9)`
1174
+ );
1175
+ }
1176
+ if (input.body === null || input.body === void 0) {
1177
+ throw new InnspelConfigError("attachment.body", "mangler \u2014 send bildet som Blob, ArrayBuffer eller Uint8Array");
1178
+ }
1179
+ const bytes = byteLengde(input.body);
1180
+ if (!Number.isFinite(bytes) || bytes < 1) {
1181
+ throw new InnspelConfigError("attachment.body", "er tom");
1182
+ }
1183
+ if (bytes > VEDLEGG_GRENSER.maxBytes) {
1184
+ throw new InnspelConfigError(
1185
+ "attachment.body",
1186
+ `er ${bytes} byte, over taket p\xE5 ${VEDLEGG_GRENSER.maxBytes} (5 MB). Reduser kvaliteten eller oppl\xF8sningen ved fangst`
1187
+ );
1188
+ }
1189
+ for (const felt of ["width", "height"]) {
1190
+ const v = input[felt];
1191
+ if (!Number.isInteger(v) || v < 1 || v > VEDLEGG_GRENSER.maxDimension) {
1192
+ throw new InnspelConfigError(
1193
+ `attachment.${felt}`,
1194
+ `m\xE5 v\xE6re et heltall mellom 1 og ${VEDLEGG_GRENSER.maxDimension} (K9)`
1195
+ );
1196
+ }
1197
+ }
1198
+ }
1199
+ async function lastOppTilLagring(fetchImpl, presign, body, signal) {
1200
+ const headers = {};
1201
+ for (const h of presign.headers) headers[h.name] = h.value;
1202
+ const res = await fetchImpl(new Request(presign.uploadUrl, { method: "PUT", headers, body, signal }));
1203
+ if (!res.ok) {
1204
+ throw new InnspelApiError(
1205
+ res.status,
1206
+ "unknown",
1207
+ `Lagringen avviste opplastingen med status ${res.status}. Bildet ble ikke lagt ved.`
1208
+ );
1209
+ }
1210
+ }
1211
+
1132
1212
  // src/versjon.ts
1133
- var VERSJON = "0.1.1";
1213
+ var VERSJON = "0.2.1";
1134
1214
 
1135
1215
  // src/klient.ts
1136
1216
  var STANDARD_TIMEOUT_MS = 1e4;
@@ -1272,6 +1352,31 @@ function init(options) {
1272
1352
  })
1273
1353
  );
1274
1354
  }
1355
+ async function lastOppVedlegg(submissionId, vedlegg) {
1356
+ const presign = await medToken(
1357
+ (h) => attachmentsPresign({
1358
+ client: klient,
1359
+ headers: h,
1360
+ signal: signal(),
1361
+ body: {
1362
+ submissionId,
1363
+ mime: vedlegg.mime,
1364
+ bytes: byteLengde(vedlegg.body),
1365
+ width: vedlegg.width,
1366
+ height: vedlegg.height
1367
+ }
1368
+ })
1369
+ );
1370
+ await lastOppTilLagring(options.fetch ?? fetch, presign, vedlegg.body, signal());
1371
+ return medToken(
1372
+ (h) => attachmentsComplete({
1373
+ client: klient,
1374
+ headers: h,
1375
+ signal: signal(),
1376
+ path: { id: presign.attachmentId }
1377
+ })
1378
+ );
1379
+ }
1275
1380
  const flushK\u00F8 = () => flushKo(
1276
1381
  k\u00F8,
1277
1382
  (rad) => sendN\u00E5({ type: rad.type, text: rad.text }, rad.clientRef, kontekst),
@@ -1297,17 +1402,12 @@ function init(options) {
1297
1402
  );
1298
1403
  }
1299
1404
  if (input.context) validerKontekst(input.context, "capture.context", false);
1405
+ if (input.attachment) validerVedlegg(input.attachment);
1300
1406
  const clientRef = input.clientRef ?? nyClientRef();
1301
1407
  const kontekstForKall = sl\u00E5SammenKontekst(options.context, input.context, VERSJON);
1408
+ let submission;
1302
1409
  try {
1303
- const submission = await sendN\u00E5(input, clientRef, kontekstForKall);
1304
- telemetri.emit({
1305
- name: "submission_sent",
1306
- type: input.type,
1307
- hasAttachment: false,
1308
- offlineQueued: false
1309
- });
1310
- return { status: "sent", submission };
1410
+ submission = await sendN\u00E5(input, clientRef, kontekstForKall);
1311
1411
  } catch (e) {
1312
1412
  if (e instanceof InnspelApiError) {
1313
1413
  telemetri.emit({ name: "submission_failed", reason: e.code });
@@ -1325,8 +1425,23 @@ function init(options) {
1325
1425
  hasAttachment: false,
1326
1426
  offlineQueued: true
1327
1427
  });
1328
- return { status: "queued", clientRef };
1428
+ return input.attachment ? { status: "queued", clientRef, attachment: { status: "dropped" } } : { status: "queued", clientRef };
1429
+ }
1430
+ let attachment;
1431
+ if (input.attachment) {
1432
+ try {
1433
+ attachment = { status: "uploaded", attachment: await lastOppVedlegg(submission.id, input.attachment) };
1434
+ } catch (e) {
1435
+ attachment = { status: "failed", error: e instanceof Error ? e : new Error(String(e)) };
1436
+ }
1329
1437
  }
1438
+ telemetri.emit({
1439
+ name: "submission_sent",
1440
+ type: input.type,
1441
+ hasAttachment: attachment?.status === "uploaded",
1442
+ offlineQueued: false
1443
+ });
1444
+ return attachment ? { status: "sent", submission, attachment } : { status: "sent", submission };
1330
1445
  },
1331
1446
  knownIssues: {
1332
1447
  async for(query = {}) {
@@ -1406,6 +1521,15 @@ function init(options) {
1406
1521
  );
1407
1522
  }
1408
1523
  },
1524
+ attachments: {
1525
+ async upload(submissionId, attachment) {
1526
+ if (typeof submissionId !== "string" || submissionId.length === 0) {
1527
+ throw new InnspelConfigError("submissionId", "mangler \u2014 bruk id fra innsendingen capture() ga");
1528
+ }
1529
+ validerVedlegg(attachment);
1530
+ return lastOppVedlegg(submissionId, attachment);
1531
+ }
1532
+ },
1409
1533
  queue: {
1410
1534
  pending: () => k\u00F8.les(),
1411
1535
  flush: flushK\u00F8
@@ -1429,13 +1553,72 @@ function init(options) {
1429
1553
  return innspel;
1430
1554
  }
1431
1555
 
1556
+ // src/maskering.ts
1557
+ function strokTilRekter(punkter, penselBredde, bilde) {
1558
+ if (penselBredde <= 0 || punkter.length === 0) return [];
1559
+ const halv = penselBredde / 2;
1560
+ const ut = [];
1561
+ const legg = (p) => {
1562
+ const r = klipp({ x: p.x - halv, y: p.y - halv, width: penselBredde, height: penselBredde }, bilde);
1563
+ if (r) ut.push(r);
1564
+ };
1565
+ legg(punkter[0]);
1566
+ for (let i = 1; i < punkter.length; i++) {
1567
+ const a = punkter[i - 1];
1568
+ const b = punkter[i];
1569
+ const avstand = Math.hypot(b.x - a.x, b.y - a.y);
1570
+ const steg = Math.max(1, Math.ceil(avstand / halv));
1571
+ for (let s = 1; s <= steg; s++) {
1572
+ legg({ x: a.x + (b.x - a.x) * s / steg, y: a.y + (b.y - a.y) * s / steg });
1573
+ }
1574
+ }
1575
+ return ut;
1576
+ }
1577
+ function trykkTilRekt(punkt, boks, bilde) {
1578
+ return klipp({ x: punkt.x - boks.width / 2, y: punkt.y - boks.height / 2, width: boks.width, height: boks.height }, bilde);
1579
+ }
1580
+ function klipp(r, bilde) {
1581
+ const x1 = Math.max(0, r.x);
1582
+ const y1 = Math.max(0, r.y);
1583
+ const x2 = Math.min(bilde.width, r.x + r.width);
1584
+ const y2 = Math.min(bilde.height, r.y + r.height);
1585
+ if (x2 <= x1 || y2 <= y1) return null;
1586
+ return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
1587
+ }
1588
+ function skaler(r, fra, til) {
1589
+ const sx = til.width / fra.width;
1590
+ const sy = til.height / fra.height;
1591
+ const x = Math.floor(r.x * sx);
1592
+ const y = Math.floor(r.y * sy);
1593
+ return {
1594
+ x,
1595
+ y,
1596
+ width: Math.ceil((r.x + r.width) * sx) - x,
1597
+ height: Math.ceil((r.y + r.height) * sy) - y
1598
+ };
1599
+ }
1600
+ function treffer(punkt, r) {
1601
+ return punkt.x >= r.x && punkt.x < r.x + r.width && punkt.y >= r.y && punkt.y < r.y + r.height;
1602
+ }
1603
+ function dekkerAlt(rekter, bilde) {
1604
+ return rekter.some((r) => r.x <= 0 && r.y <= 0 && r.x + r.width >= bilde.width && r.y + r.height >= bilde.height);
1605
+ }
1606
+
1432
1607
  exports.InnspelApiError = InnspelApiError;
1433
1608
  exports.InnspelConfigError = InnspelConfigError;
1434
1609
  exports.InnspelQueueFullError = InnspelQueueFullError;
1435
1610
  exports.KONTEKST_FELTER = KONTEKST_FELTER;
1436
1611
  exports.KO_TAK = KO_TAK;
1612
+ exports.TILLATTE_MIME = TILLATTE_MIME;
1613
+ exports.VEDLEGG_GRENSER = VEDLEGG_GRENSER;
1437
1614
  exports.VERSJON = VERSJON;
1615
+ exports.dekkerAlt = dekkerAlt;
1438
1616
  exports.init = init;
1617
+ exports.klipp = klipp;
1439
1618
  exports.scanText = scanText;
1619
+ exports.skaler = skaler;
1620
+ exports.strokTilRekter = strokTilRekter;
1621
+ exports.treffer = treffer;
1622
+ exports.trykkTilRekt = trykkTilRekt;
1440
1623
  //# sourceMappingURL=index.cjs.map
1441
1624
  //# sourceMappingURL=index.cjs.map