diff --git a/db/channels.cds b/db/channels.cds index 11955744d..0b94cd203 100644 --- a/db/channels.cds +++ b/db/channels.cds @@ -24,7 +24,7 @@ entity Channels : cuid, managed { isSapOwned : Boolean default false; category : String(60); subcategory : String(80); - platform : String(40); + platform : String(80); status : ChannelStatus default 'Active' @assert.range; focusAreas : array of String(60); tags : array of String(40); diff --git a/db/last-dev/csn.json b/db/last-dev/csn.json index 4b895b9aa..f66bf8797 100644 --- a/db/last-dev/csn.json +++ b/db/last-dev/csn.json @@ -2145,7 +2145,7 @@ }, "platform": { "type": "cds.String", - "length": 40, + "length": 80, "@cds.persistence.name": "PLATFORM" }, "status": { @@ -2431,6 +2431,80 @@ }, "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_CHANNELTOPICMAP" }, + "com.sap.developers.ims.ChannelSubmissions": { + "kind": "entity", + "@cds.persistence.journal": true, + "elements": { + "ID": { + "key": true, + "type": "cds.String", + "length": 36, + "@cds.persistence.name": "ID" + }, + "createdAt": { + "type": "cds.Timestamp", + "@cds.persistence.name": "CREATEDAT" + }, + "createdBy": { + "type": "cds.String", + "length": 255, + "@cds.persistence.name": "CREATEDBY" + }, + "modifiedAt": { + "type": "cds.Timestamp", + "@cds.persistence.name": "MODIFIEDAT" + }, + "modifiedBy": { + "type": "cds.String", + "length": 255, + "@cds.persistence.name": "MODIFIEDBY" + }, + "kind": { + "type": "cds.String", + "length": 5000, + "@cds.persistence.name": "KIND" + }, + "targetChannel_ID": { + "type": "cds.String", + "length": 36, + "@odata.foreignKey4": "targetChannel", + "@cds.persistence.name": "TARGETCHANNEL_ID" + }, + "proposed": { + "type": "cds.LargeString", + "@cds.persistence.name": "PROPOSED" + }, + "rationale": { + "type": "cds.String", + "length": 1000, + "@cds.persistence.name": "RATIONALE" + }, + "submitterId": { + "type": "cds.String", + "length": 120, + "@cds.persistence.name": "SUBMITTERID" + }, + "status": { + "type": "cds.String", + "default": { + "val": "PENDING" + }, + "length": 5000, + "@cds.persistence.name": "STATUS" + }, + "reviewerId": { + "type": "cds.String", + "length": 120, + "@cds.persistence.name": "REVIEWERID" + }, + "reviewNote": { + "type": "cds.String", + "length": 800, + "@cds.persistence.name": "REVIEWNOTE" + } + }, + "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_CHANNELSUBMISSIONS" + }, "com.sap.developers.ims.FailedEmails": { "kind": "entity", "@cds.persistence.journal": true, @@ -3733,80 +3807,6 @@ }, "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_UIEVENT" }, - "com.sap.developers.ims.ChannelSubmissions": { - "kind": "entity", - "@cds.persistence.journal": true, - "elements": { - "ID": { - "key": true, - "type": "cds.String", - "length": 36, - "@cds.persistence.name": "ID" - }, - "createdAt": { - "type": "cds.Timestamp", - "@cds.persistence.name": "CREATEDAT" - }, - "createdBy": { - "type": "cds.String", - "length": 255, - "@cds.persistence.name": "CREATEDBY" - }, - "modifiedAt": { - "type": "cds.Timestamp", - "@cds.persistence.name": "MODIFIEDAT" - }, - "modifiedBy": { - "type": "cds.String", - "length": 255, - "@cds.persistence.name": "MODIFIEDBY" - }, - "kind": { - "type": "cds.String", - "length": 5000, - "@cds.persistence.name": "KIND" - }, - "targetChannel_ID": { - "type": "cds.String", - "length": 36, - "@odata.foreignKey4": "targetChannel", - "@cds.persistence.name": "TARGETCHANNEL_ID" - }, - "proposed": { - "type": "cds.LargeString", - "@cds.persistence.name": "PROPOSED" - }, - "rationale": { - "type": "cds.String", - "length": 1000, - "@cds.persistence.name": "RATIONALE" - }, - "submitterId": { - "type": "cds.String", - "length": 120, - "@cds.persistence.name": "SUBMITTERID" - }, - "status": { - "type": "cds.String", - "default": { - "val": "PENDING" - }, - "length": 5000, - "@cds.persistence.name": "STATUS" - }, - "reviewerId": { - "type": "cds.String", - "length": 120, - "@cds.persistence.name": "REVIEWERID" - }, - "reviewNote": { - "type": "cds.String", - "length": 800, - "@cds.persistence.name": "REVIEWNOTE" - } - }, - "@cds.persistence.name": "COM_SAP_DEVELOPERS_IMS_CHANNELSUBMISSIONS" - }, "com.sap.developers.ims.CatGameAwards": { "kind": "entity", "doc": "\"Hit the Cat\" mini-game daily point ledger (issue #2042).\n\nOne row = one calendar day on which a signed-in player earned the daily\ncat-game award during a given Devtoberfest event. The award endpoint\n(POST /api/devtoberfest/cat-game/award) writes exactly one row per\n(user, event, day): 5 points per day, once per day, capped at 100 points\ntotal per event, and only while that event is active.\n\nThe natural (user, event, awardDate) tuple is the PRIMARY KEY — no surrogate\nID — so HANA enforces the once-per-day rule at the DB layer: a second insert\nfor the same day collides on the PK and the handler maps it to\n\"already-today\" (race-safe, unlike a CAP-only @assert.unique which this\nrepo's raw db.run() inserts bypass — see srv/lib/resolve-db-user.js).\n\n`createdAt` (from `managed`) is the actual award instant and is what the\ncross-container GAMEBOARD_BONUS_V1 view exposes as AWARD_DATE, so the\ngameboard's event-window filter always sees an in-window timestamp (the\nendpoint only ever inserts while the event is live).", diff --git a/db/src/com.sap.developers.ims.Channels.hdbmigrationtable b/db/src/com.sap.developers.ims.Channels.hdbmigrationtable index 0b718851c..8ee26aaeb 100644 --- a/db/src/com.sap.developers.ims.Channels.hdbmigrationtable +++ b/db/src/com.sap.developers.ims.Channels.hdbmigrationtable @@ -1,4 +1,4 @@ -== version=1 +== version=2 COLUMN TABLE com_sap_developers_ims_Channels ( ID NVARCHAR(36) NOT NULL, createdAt TIMESTAMP, @@ -17,7 +17,7 @@ COLUMN TABLE com_sap_developers_ims_Channels ( isSapOwned BOOLEAN DEFAULT FALSE, category NVARCHAR(60), subcategory NVARCHAR(80), - platform NVARCHAR(40), + platform NVARCHAR(80), status NVARCHAR(5000) DEFAULT 'Active', focusAreas NCLOB, tags NCLOB, @@ -34,3 +34,7 @@ COLUMN TABLE com_sap_developers_ims_Channels ( lastChecked TIMESTAMP, PRIMARY KEY(ID) ) + +== migration=2 +-- generated by cds-compiler version 7.0.1 +ALTER TABLE com_sap_developers_ims_Channels ALTER (platform NVARCHAR(80)); diff --git a/scripts/seed-channels.cjs b/scripts/seed-channels.cjs index 1701bde1c..084ca7108 100644 --- a/scripts/seed-channels.cjs +++ b/scripts/seed-channels.cjs @@ -4,7 +4,7 @@ // npx cds bind --exec -- node scripts/seed-channels.cjs --file d:/tmp/External-SAP-Channels-Complete.json --commit const cds = require('@sap/cds'); const { readFileSync } = require('node:fs'); -const { normalizeChannel } = require('../srv/lib/channels/normalize.js'); +const { normalizeChannel } = require('../srv/lib/channels/normalize.cjs'); const CURATED = ['isPublished', 'isFeatured', 'editorialNote', 'linkStatus', 'linkStatusOverride', 'lastChecked']; diff --git a/srv/lib/channels/normalize.js b/srv/lib/channels/normalize.cjs similarity index 76% rename from srv/lib/channels/normalize.js rename to srv/lib/channels/normalize.cjs index b8674ebba..4e63da027 100644 --- a/srv/lib/channels/normalize.js +++ b/srv/lib/channels/normalize.cjs @@ -39,6 +39,20 @@ function normalizeStatus(raw) { return { status: 'Active', note: s }; } +// Coerce an approximate count ("~1.4K", "3,200", "1.7K", 806) to an integer. +// The research dataset gives GitHub-star counts as human-readable approximations; +// the Channels.githubStars/subscribers columns are Integer. Returns null when +// there is nothing parseable. +function parseApproxCount(raw) { + if (raw == null) return null; + if (typeof raw === 'number') return Number.isFinite(raw) ? Math.round(raw) : null; + const s = String(raw).trim().replace(/^~/, '').replace(/,/g, ''); + const m = s.match(/^([0-9]*\.?[0-9]+)\s*([kKmMbB]?)$/); + if (!m) return null; + const mult = { '': 1, k: 1e3, m: 1e6, b: 1e9 }[m[2].toLowerCase()]; + return Math.round(parseFloat(m[1]) * mult); +} + // Hash only the source (dataset-owned) fields, order-independent. function computeContentHash(sourceFields) { const canonical = JSON.stringify(sourceFields, Object.keys(sourceFields).sort()); @@ -64,10 +78,10 @@ function normalizeChannel(raw, ingestBatch) { focusAreas: raw.focus_areas ?? [], tags: raw.tags ?? [], updateFrequency: raw.update_frequency ?? null, - githubStars: raw.github_stars ?? null, - subscribers: raw.subscribers ?? null, + githubStars: parseApproxCount(raw.github_stars), + subscribers: parseApproxCount(raw.subscribers), }; return { sourceId: raw.id, ...source, contentHash: computeContentHash(source), ingestBatch }; } -module.exports = { cleanCitations, normalizeOwnerType, normalizeStatus, computeContentHash, normalizeChannel }; +module.exports = { cleanCitations, normalizeOwnerType, normalizeStatus, parseApproxCount, computeContentHash, normalizeChannel }; diff --git a/test/channels-normalize.test.js b/test/channels-normalize.test.js index 666f72bdc..2f642f665 100644 --- a/test/channels-normalize.test.js +++ b/test/channels-normalize.test.js @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest'; import { cleanCitations, normalizeOwnerType, normalizeStatus, - computeContentHash, normalizeChannel, -} from '../srv/lib/channels/normalize.js'; + parseApproxCount, computeContentHash, normalizeChannel, +} from '../srv/lib/channels/normalize.cjs'; describe('channels normalize', () => { it('strips [cite:] markers and trailing space', () => { @@ -24,6 +24,25 @@ describe('channels normalize', () => { .toEqual({ status: 'Active', note: 'Canonical source' }); }); + it('parses approximate counts to integers for the Integer columns', () => { + expect(parseApproxCount('~1.4K')).toBe(1400); + expect(parseApproxCount('~3.2K')).toBe(3200); + expect(parseApproxCount('~520')).toBe(520); + expect(parseApproxCount('1,234')).toBe(1234); + expect(parseApproxCount(806)).toBe(806); + expect(parseApproxCount(null)).toBeNull(); + expect(parseApproxCount('n/a')).toBeNull(); + }); + + it('normalizeChannel coerces github_stars/subscribers to integers', () => { + const row = normalizeChannel({ + id: 'gh-1', name: 'Repo', url: 'https://x', + github_stars: '~1.4K', subscribers: 806, + }, '2026-09-03'); + expect(row.githubStars).toBe(1400); + expect(row.subscribers).toBe(806); + }); + it('content hash is stable across key order and changes with content', () => { const a = computeContentHash({ name: 'X', url: 'u', purpose: 'p' }); const b = computeContentHash({ url: 'u', purpose: 'p', name: 'X' }); diff --git a/test/channels-seed.test.js b/test/channels-seed.test.js index 4edc43fe4..177d4d2e5 100644 --- a/test/channels-seed.test.js +++ b/test/channels-seed.test.js @@ -1,7 +1,7 @@ // test/channels-seed.test.js import cds from '@sap/cds'; import { describe, it, expect, afterAll } from 'vitest'; -import { normalizeChannel } from '../srv/lib/channels/normalize.js'; +import { normalizeChannel } from '../srv/lib/channels/normalize.cjs'; const project = cds.test('serve', '--project', '.', '--in-memory'); const NS = 'com.sap.developers.ims';