@sjcrh/proteinpaint-server 2.184.1-0 → 2.186.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.
@@ -9,6 +9,11 @@ function protected_test_default() {
9
9
  canAccess: data.count >= minSize
10
10
  };
11
11
  };
12
+ ds.demoJwtInput = {
13
+ user: {
14
+ datasets: ["ABC", "XYZ"]
15
+ }
16
+ };
12
17
  return ds;
13
18
  }
14
19
  export {
@@ -417,7 +417,7 @@ function termdb_test_default() {
417
417
  src: "native",
418
418
  plots: [
419
419
  {
420
- name: "scRNA",
420
+ name: "UMAP",
421
421
  folder: "files/hg38/TermdbTest/scrna/umap",
422
422
  fileSuffix: "_umap.txt",
423
423
  colorColumns: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjcrh/proteinpaint-server",
3
- "version": "2.184.1-0",
3
+ "version": "2.186.0",
4
4
  "type": "module",
5
5
  "description": "a genomics visualization tool for exploring a cohort's genotype and phenotype data",
6
6
  "main": "src/app.js",
@@ -62,11 +62,11 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@sjcrh/augen": "2.181.1",
65
- "@sjcrh/proteinpaint-python": "2.183.2-0",
65
+ "@sjcrh/proteinpaint-python": "2.185.0",
66
66
  "@sjcrh/proteinpaint-r": "2.181.0",
67
- "@sjcrh/proteinpaint-rust": "2.183.0",
68
- "@sjcrh/proteinpaint-shared": "2.183.0",
69
- "@sjcrh/proteinpaint-types": "2.184.1-0",
67
+ "@sjcrh/proteinpaint-rust": "2.186.0",
68
+ "@sjcrh/proteinpaint-shared": "2.186.0",
69
+ "@sjcrh/proteinpaint-types": "2.186.0",
70
70
  "@types/express": "^5.0.0",
71
71
  "@types/express-session": "^1.18.1",
72
72
  "better-sqlite3": "^12.4.1",
@@ -1,6 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import serverconfig from "#src/serverconfig.js";
4
+ import { getData } from "#src/termdb.matrix.js";
4
5
  const api = {
5
6
  endpoint: "brainImagingSamples",
6
7
  methods: {
@@ -40,14 +41,24 @@ async function getBrainImageSamples(query, genomes) {
40
41
  const files = fs.readdirSync(dirPath).filter((file) => file.endsWith(".nii") && fs.statSync(path.join(dirPath, file)).isFile());
41
42
  const sampleNames = files.map((name) => name.split(".nii")[0]);
42
43
  if (q[key].sampleColumns) {
44
+ const terms = q[key].sampleColumns.map((term) => ({
45
+ $id: term.termid,
46
+ term: { id: term.termid },
47
+ q: {}
48
+ }));
49
+ const data = await getData({ terms }, ds);
50
+ if (data.error) throw data.error;
43
51
  const samples = {};
44
52
  for (const s of sampleNames) {
45
53
  const annoForOneS = { sample: s };
46
54
  const sid = ds.cohort.termdb.q.sampleName2id(s);
47
- for (const term of q[key].sampleColumns) {
48
- const v = ds.cohort.termdb.q.getSample2value(term.termid, sid);
49
- if (v[0]) {
50
- annoForOneS[term.termid] = v[0].value;
55
+ const sampleData = data.samples?.[sid];
56
+ if (sampleData) {
57
+ for (const term of q[key].sampleColumns) {
58
+ const v = sampleData[term.termid];
59
+ if (v?.value !== void 0) {
60
+ annoForOneS[term.termid] = v.value;
61
+ }
51
62
  }
52
63
  }
53
64
  samples[s] = annoForOneS;
@@ -1,4 +1,5 @@
1
1
  import { genesetEnrichmentPayload } from "#types/checkers";
2
+ import crypto from "crypto";
2
3
  import fs from "fs";
3
4
  import path from "path";
4
5
  import serverconfig from "#src/serverconfig.js";
@@ -6,6 +7,7 @@ import { run_python } from "@sjcrh/proteinpaint-python";
6
7
  import { run_rust } from "@sjcrh/proteinpaint-rust";
7
8
  import { mayLog } from "#src/helpers.ts";
8
9
  import { formatElapsedTime } from "#shared";
10
+ import { readCacheFileOrRecompute, stableStringify } from "#src/diffAnalysis.ts";
9
11
  const api = {
10
12
  endpoint: "genesetEnrichment",
11
13
  methods: {
@@ -19,18 +21,17 @@ const api = {
19
21
  }
20
22
  }
21
23
  };
22
- const cachedir_gsea = path.join(serverconfig.cachedir, "gsea");
23
24
  function init({ genomes }) {
24
25
  return async (req, res) => {
25
26
  try {
26
27
  const q = req.query;
27
28
  const results = await run_genesetEnrichment_analysis(q, genomes);
28
29
  if (!q.geneset_name) {
29
- if (typeof results != "object") throw "gsea result is not object";
30
+ if (typeof results != "object") throw new Error("gsea result is not object");
30
31
  res.send(results);
31
32
  return;
32
33
  }
33
- if (typeof results != "string") throw "gsea result is not string";
34
+ if (typeof results != "string") throw new Error("gsea result is not string");
34
35
  res.sendFile(results, (err) => {
35
36
  fs.unlink(results, () => {
36
37
  });
@@ -45,51 +46,109 @@ function init({ genomes }) {
45
46
  };
46
47
  }
47
48
  async function run_genesetEnrichment_analysis(q, genomes) {
48
- if (!genomes[q.genome].termdbs) throw "termdb database is not available for " + q.genome;
49
+ if (!genomes[q.genome].termdbs) throw new Error("termdb database is not available for " + q.genome);
50
+ if (q.fetchDE) {
51
+ const { genes, fold_change } = await resolveGseaGenesAndFoldChange({ q, genomes });
52
+ return { data: { genes, fold_change } };
53
+ }
54
+ if (q.method == "blitzgsea") {
55
+ return await computeGSEA({ q, genomes });
56
+ }
57
+ if (q.method == "cerno") {
58
+ const { genes, fold_change } = await resolveGseaGenesAndFoldChange({ q, genomes });
59
+ const genesetenrichment_input = {
60
+ genes,
61
+ fold_change,
62
+ db: genomes[q.genome].termdbs.msigdb.cohort.db.connection.name,
63
+ geneset_group: q.geneSetGroup,
64
+ genedb: path.join(serverconfig.tpmasterdir, genomes[q.genome].genedb.dbfile),
65
+ filter_non_coding_genes: q.filter_non_coding_genes
66
+ };
67
+ const time1 = (/* @__PURE__ */ new Date()).valueOf();
68
+ const gsea_output = JSON.parse(await run_rust("cerno", JSON.stringify(genesetenrichment_input)));
69
+ mayLog("Time taken to run CERNO:", formatElapsedTime(Date.now() - time1));
70
+ return gsea_output;
71
+ }
72
+ throw new Error("Unknown method:" + q.method);
73
+ }
74
+ async function resolveGseaGenesAndFoldChange({
75
+ q,
76
+ genomes
77
+ }) {
78
+ if (q.cacheId) {
79
+ if (!q.daRequest) throw new Error("daCacheMissing");
80
+ const result = await readCacheFileOrRecompute({ daRequest: q.daRequest, genomes });
81
+ if (result.cacheId !== q.cacheId) throw new Error("cacheId does not match daRequest");
82
+ return {
83
+ genes: result.geneData.map((g) => g.gene_name),
84
+ fold_change: result.geneData.map((g) => g.fold_change)
85
+ };
86
+ }
87
+ if (!q.genes || !q.fold_change) throw new Error("requires genes and fold_change when cacheId is absent");
88
+ return { genes: q.genes, fold_change: q.fold_change };
89
+ }
90
+ const pendingTracker_gsea = /* @__PURE__ */ new Map();
91
+ const pendingTracker_img = /* @__PURE__ */ new Map();
92
+ async function computeGSEA({
93
+ q,
94
+ genomes
95
+ }) {
96
+ const { genes, fold_change } = await resolveGseaGenesAndFoldChange({ q, genomes });
97
+ const gseaComputeArg = {
98
+ genes,
99
+ fold_change,
100
+ geneSetGroup: q.geneSetGroup,
101
+ num_permutations: q.num_permutations,
102
+ filter_non_coding_genes: q.filter_non_coding_genes
103
+ };
104
+ const key = stableStringify(gseaComputeArg);
105
+ const pickle_file = crypto.createHash("sha256").update(key).digest("hex").slice(0, 32) + ".pkl";
106
+ const tracker = q.geneset_name ? pendingTracker_img : pendingTracker_gsea;
107
+ const inFlight = tracker.get(pickle_file);
108
+ if (inFlight) return inFlight;
109
+ const work = runGseaPython({ q, genomes, gseaComputeArg, pickle_file });
110
+ tracker.set(pickle_file, work);
111
+ return work.finally(() => tracker.delete(pickle_file));
112
+ }
113
+ async function runGseaPython({
114
+ q,
115
+ genomes,
116
+ gseaComputeArg,
117
+ pickle_file
118
+ }) {
119
+ const cachedir_gsea = path.join(serverconfig.cachedir, "gsea");
49
120
  const genesetenrichment_input = {
50
- genes: q.genes,
51
- fold_change: q.fold_change,
121
+ genes: gseaComputeArg.genes,
122
+ fold_change: gseaComputeArg.fold_change,
52
123
  db: genomes[q.genome].termdbs.msigdb.cohort.db.connection.name,
53
- // For now msigdb has been added, but later databases other than msigdb may be used
54
- geneset_group: q.geneSetGroup,
124
+ geneset_group: gseaComputeArg.geneSetGroup,
55
125
  genedb: path.join(serverconfig.tpmasterdir, genomes[q.genome].genedb.dbfile),
56
- filter_non_coding_genes: q.filter_non_coding_genes
126
+ filter_non_coding_genes: gseaComputeArg.filter_non_coding_genes,
127
+ cachedir: cachedir_gsea,
128
+ pickle_file,
129
+ geneset_name: q.geneset_name,
130
+ num_permutations: gseaComputeArg.num_permutations
57
131
  };
58
- let gsea_output;
59
- if (q.method == "blitzgsea") {
60
- genesetenrichment_input.cachedir = cachedir_gsea;
61
- genesetenrichment_input.pickle_file = q.pickle_file;
62
- genesetenrichment_input.geneset_name = q.geneset_name;
63
- genesetenrichment_input.num_permutations = q.num_permutations;
64
- const time1 = (/* @__PURE__ */ new Date()).valueOf();
65
- gsea_output = await run_python("gsea.py", "/" + JSON.stringify(genesetenrichment_input));
66
- mayLog("Time taken to run blitzgsea:", formatElapsedTime(Date.now() - time1));
67
- let result;
68
- let data_found = false;
69
- let image_found = false;
70
- for (const line of gsea_output.split("\n")) {
71
- if (line.startsWith("result: ")) {
72
- result = JSON.parse(line.replace("result: ", ""));
73
- data_found = true;
74
- } else if (line.startsWith("image: ")) {
75
- result = JSON.parse(line.replace("image: ", ""));
76
- image_found = true;
77
- } else {
78
- mayLog(line);
79
- }
132
+ const time1 = (/* @__PURE__ */ new Date()).valueOf();
133
+ const gsea_output = await run_python("gsea.py", "/" + JSON.stringify(genesetenrichment_input));
134
+ mayLog("Time taken to run blitzgsea:", formatElapsedTime(Date.now() - time1));
135
+ let result;
136
+ let data_found = false;
137
+ let image_found = false;
138
+ for (const line of gsea_output.split("\n")) {
139
+ if (line.startsWith("result: ")) {
140
+ result = JSON.parse(line.replace("result: ", ""));
141
+ data_found = true;
142
+ } else if (line.startsWith("image: ")) {
143
+ result = JSON.parse(line.replace("image: ", ""));
144
+ image_found = true;
145
+ } else {
146
+ mayLog(line);
80
147
  }
81
- if (data_found) return result;
82
- const image_file_name = path.join(cachedir_gsea, result.image_file);
83
- if (image_found) return image_file_name;
84
- throw "data or image not found in gsea output; this should not happen";
85
- } else if (q.method == "cerno") {
86
- const time1 = (/* @__PURE__ */ new Date()).valueOf();
87
- gsea_output = JSON.parse(await run_rust("cerno", JSON.stringify(genesetenrichment_input)));
88
- mayLog("Time taken to run CERNO:", formatElapsedTime(Date.now() - time1));
89
- return gsea_output;
90
- } else {
91
- throw "Unknown method:" + q.method;
92
148
  }
149
+ if (data_found) return result;
150
+ if (image_found) return path.join(cachedir_gsea, result.image_file);
151
+ throw new Error("data or image not found in gsea output; this should not happen");
93
152
  }
94
153
  export {
95
154
  api
@@ -0,0 +1,112 @@
1
+ import { ProfileScoresPayload } from "#types/checkers";
2
+ import { getData } from "../src/termdb.matrix.js";
3
+ const api = {
4
+ endpoint: "termdb/profileRadar2Scores",
5
+ methods: {
6
+ get: {
7
+ ...ProfileScoresPayload,
8
+ init
9
+ },
10
+ post: {
11
+ ...ProfileScoresPayload,
12
+ init
13
+ }
14
+ }
15
+ };
16
+ function init({ genomes }) {
17
+ return async (req, res) => {
18
+ try {
19
+ const g = genomes[req.query.genome];
20
+ if (!g) throw "invalid genome name";
21
+ const ds = g.datasets?.[req.query.dslabel];
22
+ const result = await getScores(req.query, ds);
23
+ res.send(result);
24
+ } catch (e) {
25
+ console.log(e);
26
+ res.send({ status: "error", error: e.message || e });
27
+ }
28
+ };
29
+ }
30
+ function derivePrefix(query) {
31
+ const firstScoreId = query.scoreTerms?.[0]?.score?.term?.id;
32
+ if (firstScoreId?.startsWith("F")) return "F";
33
+ if (firstScoreId?.startsWith("A")) return "A";
34
+ for (const entry of query.filter?.lst || []) {
35
+ const id = entry.tvs?.term?.id;
36
+ if (id?.startsWith("F")) return "F";
37
+ if (id?.startsWith("A")) return "A";
38
+ }
39
+ throw "cannot determine cohort prefix from scoreTerms or filter term IDs";
40
+ }
41
+ async function getScores(query, ds) {
42
+ const { activeCohort, clientAuthResult } = query.__protected__;
43
+ const prefix = derivePrefix(query);
44
+ const facilityTermId = `${prefix}UNIT`;
45
+ const facilityTW = { term: { id: facilityTermId }, q: {} };
46
+ const terms = [facilityTW];
47
+ for (const t of query.scoreTerms) {
48
+ terms.push(t.score);
49
+ if (t.maxScore?.term) terms.push(t.maxScore);
50
+ }
51
+ if (!query.filterByUserSites) {
52
+ query.__protected__.ignoredTermIds.push(facilityTermId);
53
+ }
54
+ const cohortAuth = clientAuthResult[activeCohort];
55
+ const isPublic = !cohortAuth?.role || cohortAuth.role === "public";
56
+ const userSites = cohortAuth?.sites;
57
+ const raw = await getData(
58
+ {
59
+ terms,
60
+ filter: query.filter,
61
+ __protected__: query.__protected__
62
+ },
63
+ ds
64
+ );
65
+ if (raw.error) throw raw.error;
66
+ const sampleList = Object.values(raw.samples);
67
+ let sites = sampleList.map((s) => {
68
+ const val = s[facilityTW.$id].value;
69
+ let label = facilityTW.term.values?.[val]?.label || val;
70
+ if (label.length > 50) label = label.slice(0, 47) + "...";
71
+ return { value: val, label };
72
+ });
73
+ if (userSites && query.filterByUserSites) {
74
+ sites = sites.filter((s) => userSites.includes(s.value));
75
+ }
76
+ sites.sort((a, b) => a.label.localeCompare(b.label));
77
+ const samples = Object.values(raw.samples);
78
+ const eligibleSamples = userSites && query.filterByUserSites ? samples.filter((s) => userSites.includes(s[facilityTW.$id].value)) : samples;
79
+ const term2Score = {};
80
+ for (const d of query.scoreTerms) {
81
+ const score = computeMedianPercentage(d, eligibleSamples);
82
+ if (score !== null) term2Score[d.score.term.id] = score;
83
+ }
84
+ return {
85
+ term2Score,
86
+ sites: isPublic ? [] : sites,
87
+ n: eligibleSamples.length
88
+ };
89
+ }
90
+ function computeMedianPercentage(d, samples) {
91
+ const percentages = [];
92
+ for (const s of samples) {
93
+ const scoreValue = s[d.score.$id]?.value;
94
+ if (scoreValue == null) continue;
95
+ let maxScoreValue = null;
96
+ if (typeof d.maxScore === "number") {
97
+ maxScoreValue = d.maxScore;
98
+ } else {
99
+ maxScoreValue = s[d.maxScore.$id]?.value;
100
+ }
101
+ if (maxScoreValue == null || maxScoreValue === 0) continue;
102
+ percentages.push(scoreValue / maxScoreValue * 100);
103
+ }
104
+ if (percentages.length === 0) return null;
105
+ percentages.sort((a, b) => a - b);
106
+ const mid = Math.floor(percentages.length / 2);
107
+ const median = percentages.length % 2 !== 0 ? percentages[mid] : (percentages[mid - 1] + percentages[mid]) / 2;
108
+ return Math.round(median);
109
+ }
110
+ export {
111
+ api
112
+ };
@@ -0,0 +1,148 @@
1
+ import { ProfileScoresPayload } from "#types/checkers";
2
+ import { getData } from "../src/termdb.matrix.js";
3
+ const api = {
4
+ endpoint: "termdb/profileRadarFacility2Scores",
5
+ methods: {
6
+ get: {
7
+ ...ProfileScoresPayload,
8
+ init
9
+ },
10
+ post: {
11
+ ...ProfileScoresPayload,
12
+ init
13
+ }
14
+ }
15
+ };
16
+ function init({ genomes }) {
17
+ return async (req, res) => {
18
+ try {
19
+ const g = genomes[req.query.genome];
20
+ if (!g) throw "invalid genome name";
21
+ const ds = g.datasets?.[req.query.dslabel];
22
+ const result = await getScores(req.query, ds);
23
+ res.send(result);
24
+ } catch (e) {
25
+ console.log(e);
26
+ res.send({ status: "error", error: e.message || e });
27
+ }
28
+ };
29
+ }
30
+ function derivePrefix(query) {
31
+ const firstScoreId = query.scoreTerms?.[0]?.score?.term?.id;
32
+ if (firstScoreId?.startsWith("F")) return "F";
33
+ if (firstScoreId?.startsWith("A")) return "A";
34
+ for (const entry of query.filter?.lst || []) {
35
+ const id = entry.tvs?.term?.id;
36
+ if (id?.startsWith("F")) return "F";
37
+ if (id?.startsWith("A")) return "A";
38
+ }
39
+ throw "cannot determine cohort prefix from scoreTerms or filter term IDs";
40
+ }
41
+ async function getScores(query, ds) {
42
+ const { activeCohort, clientAuthResult } = query.__protected__;
43
+ const prefix = derivePrefix(query);
44
+ const facilityTermId = `${prefix}UNIT`;
45
+ const facilityTW = { term: { id: facilityTermId }, q: {} };
46
+ const terms = [facilityTW];
47
+ for (const t of query.scoreTerms) {
48
+ terms.push(t.score);
49
+ if (t.maxScore?.term) terms.push(t.maxScore);
50
+ }
51
+ if (!query.filterByUserSites) {
52
+ query.__protected__.ignoredTermIds.push(facilityTermId);
53
+ }
54
+ const cohortAuth = clientAuthResult[activeCohort];
55
+ const isPublic = !cohortAuth?.role || cohortAuth.role === "public";
56
+ const isAdmin = cohortAuth?.role === "admin";
57
+ const userSites = cohortAuth?.sites;
58
+ const raw = await getData(
59
+ {
60
+ terms,
61
+ filter: query.filter,
62
+ __protected__: query.__protected__
63
+ },
64
+ ds
65
+ );
66
+ if (raw.error) throw raw.error;
67
+ const samples = Object.values(raw.samples);
68
+ let sites = samples.map((s) => {
69
+ const val = s[facilityTW.$id].value;
70
+ let label = facilityTW.term.values?.[val]?.label || val;
71
+ if (label.length > 50) label = label.slice(0, 47) + "...";
72
+ return { value: val, label };
73
+ });
74
+ if (userSites && !isAdmin) {
75
+ sites = sites.filter((s) => userSites.includes(s.value));
76
+ }
77
+ sites.sort((a, b) => a.label.localeCompare(b.label));
78
+ const eligibleSamples = userSites && query.filterByUserSites ? samples.filter((s) => userSites.includes(s[facilityTW.$id].value)) : samples;
79
+ const aggregateScore = {};
80
+ for (const d of query.scoreTerms) {
81
+ const score = computeMedianPercentage(d, eligibleSamples);
82
+ if (score !== null) aggregateScore[d.score.term.id] = score;
83
+ }
84
+ let targetSiteValue = null;
85
+ if (query.facilitySite) {
86
+ if (isAdmin) targetSiteValue = query.facilitySite;
87
+ else if (userSites?.includes(query.facilitySite)) targetSiteValue = query.facilitySite;
88
+ }
89
+ if (!targetSiteValue && sites.length > 0) targetSiteValue = sites[0].value;
90
+ let sampleData = void 0;
91
+ if (!isPublic && targetSiteValue) {
92
+ const sampleRow = samples.find((s) => s[facilityTW.$id].value == targetSiteValue);
93
+ if (sampleRow) {
94
+ const site = sites.find((s) => s.value == targetSiteValue) || {
95
+ value: targetSiteValue,
96
+ label: facilityTW.term.values?.[targetSiteValue]?.label || targetSiteValue
97
+ };
98
+ const singleSiteScore = {};
99
+ for (const d of query.scoreTerms) {
100
+ const percent = computeSinglePercentage(d, sampleRow);
101
+ if (percent !== null) singleSiteScore[d.score.term.id] = percent;
102
+ }
103
+ sampleData = { term2Score: singleSiteScore, site, sites, n: 1 };
104
+ }
105
+ }
106
+ return {
107
+ term2Score: aggregateScore,
108
+ sampleData,
109
+ // Public users: empty (chart should be unreachable anyway via isSupportedChartOverride)
110
+ sites: isPublic ? [] : sites,
111
+ n: eligibleSamples.length
112
+ };
113
+ }
114
+ function computeSinglePercentage(d, sample) {
115
+ const scoreValue = sample[d.score.$id]?.value;
116
+ if (scoreValue == null) return null;
117
+ let maxScoreValue = null;
118
+ if (typeof d.maxScore === "number") {
119
+ maxScoreValue = d.maxScore;
120
+ } else {
121
+ maxScoreValue = sample[d.maxScore.$id]?.value;
122
+ }
123
+ if (maxScoreValue == null || maxScoreValue === 0) return null;
124
+ return Math.round(scoreValue / maxScoreValue * 100);
125
+ }
126
+ function computeMedianPercentage(d, samples) {
127
+ const percentages = [];
128
+ for (const s of samples) {
129
+ const scoreValue = s[d.score.$id]?.value;
130
+ if (scoreValue == null) continue;
131
+ let maxScoreValue = null;
132
+ if (typeof d.maxScore === "number") {
133
+ maxScoreValue = d.maxScore;
134
+ } else {
135
+ maxScoreValue = s[d.maxScore.$id]?.value;
136
+ }
137
+ if (maxScoreValue == null || maxScoreValue === 0) continue;
138
+ percentages.push(scoreValue / maxScoreValue * 100);
139
+ }
140
+ if (percentages.length === 0) return null;
141
+ percentages.sort((a, b) => a - b);
142
+ const mid = Math.floor(percentages.length / 2);
143
+ const median = percentages.length % 2 !== 0 ? percentages[mid] : (percentages[mid - 1] + percentages[mid]) / 2;
144
+ return Math.round(median);
145
+ }
146
+ export {
147
+ api
148
+ };
@@ -75,6 +75,27 @@ function validateQuery(ds, connection) {
75
75
  };
76
76
  }
77
77
  const imageId = imageRow.id;
78
+ const duplicateCheckSql = `
79
+ SELECT id
80
+ FROM project_annotations
81
+ WHERE project_id = ?
82
+ AND image_id = ?
83
+ AND coordinates = ?
84
+ LIMIT 1
85
+ `;
86
+ const duplicateCheckStmt = connection.prepare(duplicateCheckSql);
87
+ const duplicateRow = duplicateCheckStmt.get(projectId, imageId, coords);
88
+ if (duplicateRow) {
89
+ const deleteDuplicateSql = `
90
+ DELETE FROM project_annotations
91
+ WHERE id = ?
92
+ `;
93
+ const deleteDuplicateStmt = connection.prepare(deleteDuplicateSql);
94
+ deleteDuplicateStmt.run(duplicateRow.id);
95
+ console.log(
96
+ `Deleted duplicate annotation with id=${duplicateRow.id} for project_id=${projectId}, image_id=${imageId}.`
97
+ );
98
+ }
78
99
  const insertSql = `
79
100
  INSERT INTO project_annotations (
80
101
  project_id, user_id, coordinates, timestamp, status, class_id, image_id