@striae-org/striae 4.2.1 → 4.3.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.
Files changed (47) hide show
  1. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  2. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  3. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  4. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  5. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  6. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  7. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  8. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  9. package/app/components/sidebar/cases/case-sidebar.tsx +49 -3
  10. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  11. package/app/components/sidebar/cases/cases-modal.tsx +690 -110
  12. package/app/components/sidebar/cases/cases.module.css +23 -0
  13. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  14. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  15. package/app/components/sidebar/files/files-modal.module.css +285 -44
  16. package/app/components/sidebar/files/files-modal.tsx +452 -145
  17. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  18. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  19. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  20. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  21. package/app/components/sidebar/notes/notes-editor-form.tsx +43 -5
  22. package/app/components/sidebar/notes/notes.module.css +236 -4
  23. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  24. package/app/components/sidebar/sidebar-container.tsx +1 -0
  25. package/app/components/sidebar/sidebar.tsx +12 -1
  26. package/app/hooks/useCaseListPreferences.ts +99 -0
  27. package/app/hooks/useFileListPreferences.ts +106 -0
  28. package/app/routes/striae/striae.tsx +1 -0
  29. package/app/types/annotations.ts +48 -1
  30. package/app/utils/data/case-filters.ts +127 -0
  31. package/app/utils/data/confirmation-summary/summary-core.ts +18 -2
  32. package/app/utils/data/file-filters.ts +201 -0
  33. package/functions/api/image/[[path]].ts +4 -0
  34. package/package.json +3 -4
  35. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  36. package/workers/data-worker/wrangler.jsonc.example +1 -1
  37. package/workers/image-worker/wrangler.jsonc.example +1 -1
  38. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  39. package/workers/pdf-worker/src/formats/format-striae.ts +84 -118
  40. package/workers/pdf-worker/src/pdf-worker.example.ts +28 -10
  41. package/workers/pdf-worker/src/report-layout.ts +227 -0
  42. package/workers/pdf-worker/src/report-types.ts +20 -0
  43. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  44. package/workers/user-worker/wrangler.jsonc.example +1 -1
  45. package/wrangler.toml.example +1 -1
  46. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  47. /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
@@ -22,19 +22,66 @@ export interface ConfirmationData {
22
22
  confirmedAt: string; // ISO timestamp of confirmation
23
23
  }
24
24
 
25
+ export interface BulletAnnotationData {
26
+ caliber?: string;
27
+ mass?: string;
28
+ diameter?: string;
29
+ calcDiameter?: string;
30
+ lgNumber?: number;
31
+ lgDirection?: string;
32
+ barrelType?: string;
33
+ // Width arrays should align with lgNumber:
34
+ // L1..Ln stored in order at lWidths[0..n-1], G1..Gn at gWidths[0..n-1].
35
+ lWidths?: string[];
36
+ gWidths?: string[];
37
+ jacketMetal?: string;
38
+ coreMetal?: string;
39
+ bulletType?: string;
40
+ }
41
+
42
+ export interface CartridgeCaseAnnotationData {
43
+ caliber?: string;
44
+ brand?: string;
45
+ metal?: string;
46
+ primerType?: string;
47
+ fpiShape?: string;
48
+ apertureShape?: string;
49
+ hasFpDrag?: boolean;
50
+ hasExtractorMarks?: boolean;
51
+ hasEjectorMarks?: boolean;
52
+ hasChamberMarks?: boolean;
53
+ hasMagazineLipMarks?: boolean;
54
+ hasPrimerShear?: boolean;
55
+ hasEjectionPortMarks?: boolean;
56
+ }
57
+
58
+ export interface ShotshellAnnotationData {
59
+ gauge?: string;
60
+ shotSize?: string;
61
+ metal?: string;
62
+ brand?: string;
63
+ fpiShape?: string;
64
+ hasExtractorMarks?: boolean;
65
+ hasEjectorMarks?: boolean;
66
+ hasChamberMarks?: boolean;
67
+ }
68
+
25
69
  export interface AnnotationData {
26
70
  leftCase: string;
27
71
  rightCase: string;
28
72
  leftItem: string;
29
73
  rightItem: string;
30
74
  caseFontColor?: string;
31
- classType?: 'Bullet' | 'Cartridge Case' | 'Other';
75
+ classType?: 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other';
32
76
  customClass?: string;
33
77
  classNote?: string;
34
78
  indexType?: 'number' | 'color';
35
79
  indexNumber?: string;
36
80
  indexColor?: string;
37
81
  supportLevel?: 'ID' | 'Exclusion' | 'Inconclusive';
82
+ bulletData?: BulletAnnotationData;
83
+ cartridgeCaseData?: CartridgeCaseAnnotationData;
84
+ shotshellData?: ShotshellAnnotationData;
38
85
  hasSubclass?: boolean;
39
86
  includeConfirmation: boolean;
40
87
  confirmationData?: ConfirmationData;
@@ -0,0 +1,127 @@
1
+ export type CasesModalSortBy = 'recent' | 'alphabetical';
2
+
3
+ export type CasesModalConfirmationFilter =
4
+ | 'all'
5
+ | 'pending'
6
+ | 'confirmed'
7
+ | 'none-requested';
8
+
9
+ export interface CasesModalPreferences {
10
+ sortBy: CasesModalSortBy;
11
+ confirmationFilter: CasesModalConfirmationFilter;
12
+ showArchivedOnly: boolean;
13
+ }
14
+
15
+ export interface CasesModalCaseItem {
16
+ caseNumber: string;
17
+ createdAt: string;
18
+ archived: boolean;
19
+ isReadOnly: boolean;
20
+ }
21
+
22
+ export interface CaseConfirmationStatusValue {
23
+ includeConfirmation: boolean;
24
+ isConfirmed: boolean;
25
+ }
26
+
27
+ const DEFAULT_CASE_CONFIRMATION_STATUS: CaseConfirmationStatusValue = {
28
+ includeConfirmation: false,
29
+ isConfirmed: false,
30
+ };
31
+
32
+ function compareCaseNumbersAlphabetically(a: string, b: string): number {
33
+ const getComponents = (value: string) => {
34
+ const numbers = value.match(/\d+/g)?.map(Number) || [];
35
+ const letters = value.match(/[A-Za-z]+/g)?.join('') || '';
36
+ return { numbers, letters };
37
+ };
38
+
39
+ const left = getComponents(a);
40
+ const right = getComponents(b);
41
+
42
+ const maxLength = Math.max(left.numbers.length, right.numbers.length);
43
+ for (let index = 0; index < maxLength; index += 1) {
44
+ const leftNumber = left.numbers[index] || 0;
45
+ const rightNumber = right.numbers[index] || 0;
46
+
47
+ if (leftNumber !== rightNumber) {
48
+ return leftNumber - rightNumber;
49
+ }
50
+ }
51
+
52
+ return left.letters.localeCompare(right.letters);
53
+ }
54
+
55
+ function parseTimestamp(value: string): number {
56
+ const parsed = Date.parse(value);
57
+ return Number.isNaN(parsed) ? 0 : parsed;
58
+ }
59
+
60
+ function matchesConfirmationFilter(
61
+ caseNumber: string,
62
+ confirmationFilter: CasesModalConfirmationFilter,
63
+ caseConfirmationStatus: Record<string, CaseConfirmationStatusValue>
64
+ ): boolean {
65
+ if (confirmationFilter === 'all') {
66
+ return true;
67
+ }
68
+
69
+ const status = caseConfirmationStatus[caseNumber] || DEFAULT_CASE_CONFIRMATION_STATUS;
70
+
71
+ if (confirmationFilter === 'pending') {
72
+ return status.includeConfirmation && !status.isConfirmed;
73
+ }
74
+
75
+ if (confirmationFilter === 'confirmed') {
76
+ return status.includeConfirmation && status.isConfirmed;
77
+ }
78
+
79
+ return !status.includeConfirmation;
80
+ }
81
+
82
+ export function filterCasesForModal(
83
+ cases: CasesModalCaseItem[],
84
+ preferences: CasesModalPreferences,
85
+ caseConfirmationStatus: Record<string, CaseConfirmationStatusValue>
86
+ ): CasesModalCaseItem[] {
87
+ const archiveFilteredCases = preferences.showArchivedOnly
88
+ ? cases.filter((entry) => entry.archived && !entry.isReadOnly)
89
+ : cases.filter((entry) => !entry.archived && !entry.isReadOnly);
90
+
91
+ return archiveFilteredCases.filter((entry) =>
92
+ matchesConfirmationFilter(entry.caseNumber, preferences.confirmationFilter, caseConfirmationStatus)
93
+ );
94
+ }
95
+
96
+ export function sortCasesForModal(
97
+ cases: CasesModalCaseItem[],
98
+ sortBy: CasesModalSortBy
99
+ ): CasesModalCaseItem[] {
100
+ const next = [...cases];
101
+
102
+ if (sortBy === 'recent') {
103
+ return next.sort((left, right) => {
104
+ const difference = parseTimestamp(right.createdAt) - parseTimestamp(left.createdAt);
105
+ if (difference !== 0) {
106
+ return difference;
107
+ }
108
+
109
+ return compareCaseNumbersAlphabetically(left.caseNumber, right.caseNumber);
110
+ });
111
+ }
112
+
113
+ return next.sort((left, right) =>
114
+ compareCaseNumbersAlphabetically(left.caseNumber, right.caseNumber)
115
+ );
116
+ }
117
+
118
+ export function getCasesForModal(
119
+ cases: CasesModalCaseItem[],
120
+ preferences: CasesModalPreferences,
121
+ caseConfirmationStatus: Record<string, CaseConfirmationStatusValue>
122
+ ): CasesModalCaseItem[] {
123
+ return sortCasesForModal(
124
+ filterCasesForModal(cases, preferences, caseConfirmationStatus),
125
+ preferences.sortBy
126
+ );
127
+ }
@@ -5,6 +5,7 @@ export interface FileConfirmationSummary {
5
5
  includeConfirmation: boolean;
6
6
  isConfirmed: boolean;
7
7
  updatedAt: string;
8
+ classType?: 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other';
8
9
  }
9
10
 
10
11
  export interface CaseConfirmationSummary {
@@ -194,11 +195,20 @@ function normalizeFileConfirmationSummary(value: unknown): FileConfirmationSumma
194
195
  };
195
196
  }
196
197
 
197
- return {
198
+ const classType = value.classType;
199
+ const normalizedClassType = typeof classType === 'string' && ['Bullet', 'Cartridge Case', 'Shotshell', 'Other'].includes(classType) ? (classType as 'Bullet' | 'Cartridge Case' | 'Shotshell' | 'Other') : undefined;
200
+
201
+ const summary: FileConfirmationSummary = {
198
202
  includeConfirmation: value.includeConfirmation === true,
199
203
  isConfirmed: value.isConfirmed === true,
200
204
  updatedAt: typeof value.updatedAt === 'string' && value.updatedAt.length > 0 ? value.updatedAt : getIsoNow()
201
205
  };
206
+
207
+ if (normalizedClassType) {
208
+ summary.classType = normalizedClassType;
209
+ }
210
+
211
+ return summary;
202
212
  }
203
213
 
204
214
  export function isStaleTimestamp(timestamp: string, maxAgeMs: number): boolean {
@@ -228,11 +238,17 @@ export function computeCaseConfirmationAggregate(filesById: Record<string, FileC
228
238
  export function toFileConfirmationSummary(annotationData: AnnotationData | null): FileConfirmationSummary {
229
239
  const includeConfirmation = annotationData?.includeConfirmation === true;
230
240
 
231
- return {
241
+ const summary: FileConfirmationSummary = {
232
242
  includeConfirmation,
233
243
  isConfirmed: includeConfirmation && !!annotationData?.confirmationData,
234
244
  updatedAt: getIsoNow()
235
245
  };
246
+
247
+ if (annotationData?.classType) {
248
+ summary.classType = annotationData.classType;
249
+ }
250
+
251
+ return summary;
236
252
  }
237
253
 
238
254
  export function normalizeConfirmationSummaryDocument(payload: unknown): UserConfirmationSummaryDocument {
@@ -0,0 +1,201 @@
1
+ import type { FileData } from '~/types';
2
+ import type { FileConfirmationSummary } from '~/utils/data';
3
+
4
+ export type FilesModalSortBy = 'recent' | 'filename' | 'confirmation' | 'classType';
5
+
6
+ export type FilesModalConfirmationFilter =
7
+ | 'all'
8
+ | 'pending'
9
+ | 'confirmed'
10
+ | 'none-requested';
11
+
12
+ export type FilesModalClassTypeFilter =
13
+ | 'all'
14
+ | 'Bullet'
15
+ | 'Cartridge Case'
16
+ | 'Shotshell'
17
+ | 'Other';
18
+
19
+ export interface FilesModalPreferences {
20
+ sortBy: FilesModalSortBy;
21
+ confirmationFilter: FilesModalConfirmationFilter;
22
+ classTypeFilter: FilesModalClassTypeFilter;
23
+ }
24
+
25
+ export type FileConfirmationById = Record<string, FileConfirmationSummary>;
26
+
27
+ const DEFAULT_CONFIRMATION_SUMMARY: FileConfirmationSummary = {
28
+ includeConfirmation: false,
29
+ isConfirmed: false,
30
+ updatedAt: '',
31
+ };
32
+
33
+ function getFileConfirmationState(fileId: string, statusById: FileConfirmationById): FileConfirmationSummary {
34
+ return statusById[fileId] || DEFAULT_CONFIRMATION_SUMMARY;
35
+ }
36
+
37
+ function getConfirmationRank(summary: FileConfirmationSummary): number {
38
+ if (summary.includeConfirmation && !summary.isConfirmed) {
39
+ return 0;
40
+ }
41
+
42
+ if (summary.includeConfirmation && summary.isConfirmed) {
43
+ return 1;
44
+ }
45
+
46
+ return 2;
47
+ }
48
+
49
+ function getClassTypeRank(classType: FileConfirmationSummary['classType']): number {
50
+ if (classType === 'Bullet') {
51
+ return 0;
52
+ }
53
+
54
+ if (classType === 'Cartridge Case') {
55
+ return 1;
56
+ }
57
+
58
+ if (classType === 'Shotshell') {
59
+ return 2;
60
+ }
61
+
62
+ if (classType === 'Other') {
63
+ return 3;
64
+ }
65
+
66
+ return 4;
67
+ }
68
+
69
+ function parseTimestamp(value: string): number {
70
+ const parsed = Date.parse(value);
71
+ return Number.isNaN(parsed) ? 0 : parsed;
72
+ }
73
+
74
+ function matchesConfirmationFilter(
75
+ summary: FileConfirmationSummary,
76
+ confirmationFilter: FilesModalConfirmationFilter
77
+ ): boolean {
78
+ if (confirmationFilter === 'all') {
79
+ return true;
80
+ }
81
+
82
+ if (confirmationFilter === 'pending') {
83
+ return summary.includeConfirmation && !summary.isConfirmed;
84
+ }
85
+
86
+ if (confirmationFilter === 'confirmed') {
87
+ return summary.includeConfirmation && summary.isConfirmed;
88
+ }
89
+
90
+ return !summary.includeConfirmation;
91
+ }
92
+
93
+ function matchesClassTypeFilter(
94
+ summary: FileConfirmationSummary,
95
+ classTypeFilter: FilesModalClassTypeFilter
96
+ ): boolean {
97
+ if (classTypeFilter === 'all') {
98
+ return true;
99
+ }
100
+
101
+ if (classTypeFilter === 'Other') {
102
+ // Treat legacy/unset class types as Other for filtering.
103
+ return summary.classType === 'Other' || !summary.classType;
104
+ }
105
+
106
+ return summary.classType === classTypeFilter;
107
+ }
108
+
109
+ function matchesSearch(file: FileData, query: string): boolean {
110
+ const normalized = query.trim().toLowerCase();
111
+ if (!normalized) {
112
+ return true;
113
+ }
114
+
115
+ return file.originalFilename.toLowerCase().includes(normalized);
116
+ }
117
+
118
+ export function filterFilesForModal(
119
+ files: FileData[],
120
+ preferences: FilesModalPreferences,
121
+ statusById: FileConfirmationById,
122
+ searchQuery: string
123
+ ): FileData[] {
124
+ return files.filter((file) => {
125
+ const summary = getFileConfirmationState(file.id, statusById);
126
+
127
+ return (
128
+ matchesSearch(file, searchQuery) &&
129
+ matchesConfirmationFilter(summary, preferences.confirmationFilter) &&
130
+ matchesClassTypeFilter(summary, preferences.classTypeFilter)
131
+ );
132
+ });
133
+ }
134
+
135
+ function compareFileNames(a: string, b: string): number {
136
+ return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
137
+ }
138
+
139
+ export function sortFilesForModal(
140
+ files: FileData[],
141
+ sortBy: FilesModalSortBy,
142
+ statusById: FileConfirmationById
143
+ ): FileData[] {
144
+ const next = [...files];
145
+
146
+ if (sortBy === 'recent') {
147
+ return next.sort((left, right) => {
148
+ const difference = parseTimestamp(right.uploadedAt) - parseTimestamp(left.uploadedAt);
149
+ if (difference !== 0) {
150
+ return difference;
151
+ }
152
+
153
+ return compareFileNames(left.originalFilename, right.originalFilename);
154
+ });
155
+ }
156
+
157
+ if (sortBy === 'filename') {
158
+ return next.sort((left, right) =>
159
+ compareFileNames(left.originalFilename, right.originalFilename)
160
+ );
161
+ }
162
+
163
+ if (sortBy === 'confirmation') {
164
+ return next.sort((left, right) => {
165
+ const leftSummary = getFileConfirmationState(left.id, statusById);
166
+ const rightSummary = getFileConfirmationState(right.id, statusById);
167
+ const difference = getConfirmationRank(leftSummary) - getConfirmationRank(rightSummary);
168
+
169
+ if (difference !== 0) {
170
+ return difference;
171
+ }
172
+
173
+ return compareFileNames(left.originalFilename, right.originalFilename);
174
+ });
175
+ }
176
+
177
+ return next.sort((left, right) => {
178
+ const leftSummary = getFileConfirmationState(left.id, statusById);
179
+ const rightSummary = getFileConfirmationState(right.id, statusById);
180
+ const difference = getClassTypeRank(leftSummary.classType) - getClassTypeRank(rightSummary.classType);
181
+
182
+ if (difference !== 0) {
183
+ return difference;
184
+ }
185
+
186
+ return compareFileNames(left.originalFilename, right.originalFilename);
187
+ });
188
+ }
189
+
190
+ export function getFilesForModal(
191
+ files: FileData[],
192
+ preferences: FilesModalPreferences,
193
+ statusById: FileConfirmationById,
194
+ searchQuery: string
195
+ ): FileData[] {
196
+ return sortFilesForModal(
197
+ filterFilesForModal(files, preferences, statusById, searchQuery),
198
+ preferences.sortBy,
199
+ statusById
200
+ );
201
+ }
@@ -53,6 +53,10 @@ function extractProxyPath(url: URL): ProxyPathResult {
53
53
 
54
54
  try {
55
55
  const decodedPath = decodeURIComponent(encodedPath);
56
+ if (decodedPath.includes('?') || decodedPath.includes('#')) {
57
+ return { ok: false, reason: 'bad-encoding' };
58
+ }
59
+
56
60
  return { ok: true, path: decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}` };
57
61
  } catch {
58
62
  return { ok: false, reason: 'bad-encoding' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@striae-org/striae",
3
- "version": "4.2.1",
3
+ "version": "4.3.0",
4
4
  "private": false,
5
5
  "description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
6
6
  "license": "Apache-2.0",
@@ -54,10 +54,9 @@
54
54
  "workers/*/src/*.example.ts",
55
55
  "workers/*/src/*.example.js",
56
56
  "workers/*/src/*.ts",
57
- "workers/pdf-worker/scripts/*.js",
58
- "workers/pdf-worker/src/assets/icon-256.png",
57
+ "workers/pdf-worker/scripts/*.js",
59
58
  "!workers/*/src/*worker.ts",
60
- "workers/pdf-worker/src/assets/generated-assets.ts",
59
+ "workers/pdf-worker/src/assets/generated-assets.example.ts",
61
60
  "workers/pdf-worker/src/formats/format-striae.ts",
62
61
  "workers/pdf-worker/src/report-types.ts",
63
62
  "workers/*/wrangler.jsonc.example",
@@ -2,7 +2,7 @@
2
2
  "name": "AUDIT_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/audit-worker.ts",
5
- "compatibility_date": "2026-03-21",
5
+ "compatibility_date": "2026-03-22",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -3,7 +3,7 @@
3
3
  "name": "DATA_WORKER_NAME",
4
4
  "account_id": "ACCOUNT_ID",
5
5
  "main": "src/data-worker.ts",
6
- "compatibility_date": "2026-03-21",
6
+ "compatibility_date": "2026-03-22",
7
7
  "compatibility_flags": [
8
8
  "nodejs_compat"
9
9
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "IMAGES_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/image-worker.ts",
5
- "compatibility_date": "2026-03-21",
5
+ "compatibility_date": "2026-03-22",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],
@@ -2,7 +2,7 @@
2
2
  "name": "KEYS_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/keys.ts",
5
- "compatibility_date": "2026-03-21",
5
+ "compatibility_date": "2026-03-22",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],