From e2812ea6c6a27e5adee315b5c430f53f6df2cb8e Mon Sep 17 00:00:00 2001 From: leiforion Date: Wed, 18 Feb 2026 00:48:37 +0400 Subject: [PATCH 1/3] Create datamage RTD integration --- modules/datamageRrdProvider.md | 79 +++++ modules/datamageRtdProvider.js | 325 ++++++++++++++++++ test/spec/modules/datamageRtdProvider_spec.js | 245 +++++++++++++ test_datamage.html | 208 +++++++++++ 4 files changed, 857 insertions(+) create mode 100644 modules/datamageRrdProvider.md create mode 100644 modules/datamageRtdProvider.js create mode 100644 test/spec/modules/datamageRtdProvider_spec.js create mode 100644 test_datamage.html diff --git a/modules/datamageRrdProvider.md b/modules/datamageRrdProvider.md new file mode 100644 index 0000000000..11e131c3fd --- /dev/null +++ b/modules/datamageRrdProvider.md @@ -0,0 +1,79 @@ +# DataMage RTD Submodule + +DataMage provides contextual classification (IAB Categories, Sentiment, Brands, Locations, Public Figures, Restricted Categories, and related IDs) that can be used to enrich demand signals and Google Ad Manager targeting. + +## What it does + +DataMage supports two outcomes in a Prebid + GAM setup: + +1) **Passes data to bidders (ORTB2 enrichment)** +- DataMage fetches classification for the current page/content. +- The results are inserted into the bid request using OpenRTB (ORTB2), so bidders can receive the contextual signal. + +2) **Passes data to Google Ad Manager (direct GPT targeting)** +- DataMage publishes a targeting map on the page (`window.__DATAMAGE_GPT_TARGETING__`) and emits an event (`datamage:gptTargeting`). +- Your page then sets those key-values into GPT/GAM using `googletag.pubads().setTargeting(...)`. +- This works **even if there are no bids**, as long as GPT is refreshed after targeting is set. + +## Keys provided + +DataMage can provide the following keys (when available): + +- `om_iab_cat_ids`, `om_iab_cats` +- `om_brand_ids`, `om_brands` +- `om_sentiment_ids`, `om_sentiment` +- `om_location_ids`, `om_locations` +- `om_public_figure_ids`, `om_public_figures` +- `om_restricted_cat_ids`, `om_restricted_cats` +- `om_ops_mage_data_id` +- `om_res_score_bucket` +- `om_res_score` (only when present) + +> Publisher domain keys are not used. + +## Integration + +### 1) Build Prebid.js with DataMage +Include the module in your Prebid build: +```bash +gulp build --modules=datamageRtdProvider,... +``` + +### 2) Enable the RTD provider in Prebid config +Example: +```js +pbjs.setConfig({ + realTimeData: { + auctionDelay: 500, + dataProviders: [{ + name: "datamage", + params: { + api_key: "YOUR_API_KEY", + selector: "article", + auction_timeout_ms: 0, + fetch_timeout_ms: 2500 + } + }] + } +}); +``` + +### 3) GAM (GPT) setup requirements +To ensure DataMage key-values are included in the GAM request: + +1. Call `googletag.pubads().disableInitialLoad()` before the ad request. +2. Define the slot and keep a reference to it. +3. Call `googletag.display()` once (no request yet because initial load is disabled). +4. Run `pbjs.requestBids(...)`. +5. After bids return: + - Call `pbjs.setTargetingForGPTAsync()` (for hb_* keys when bids exist). + - Wait for DataMage targeting (`window.__DATAMAGE_GPT_TARGETING__` or the `datamage:gptTargeting` event). + - Apply DataMage targeting via `googletag.pubads().setTargeting(...)`. +6. Call `googletag.pubads().refresh([slot])` to make the GAM request. + +This sequence ensures: +- DataMage targeting reaches GAM +- ORTB2 enrichment reaches bidders +- DataMage targeting can still be applied even if there are no bids + +Note: Datamage api URLs will cache for 5 minutes, so you may not see content return until the cache has cleared. \ No newline at end of file diff --git a/modules/datamageRtdProvider.js b/modules/datamageRtdProvider.js new file mode 100644 index 0000000000..ae36ecd593 --- /dev/null +++ b/modules/datamageRtdProvider.js @@ -0,0 +1,325 @@ +import { submodule } from '../src/hook.js'; +import { logError, logWarn, generateUUID } from '../src/utils.js'; + +// eslint-disable-next-line no-console +console.log('%c DATAMAGE: Module file loaded successfully ', 'background: #222; color: #bada55'); + +const MODULE_NAME = 'datamage'; + +// Cache of latest targeting payload (string scalars + comma-joined lists for legacy use) +let lastTargeting = null; + +// ✅ TEST-ONLY: allows spec to reset module-scoped state between tests +function _resetForTest() { + lastTargeting = null; +} + +/** + * RTD init() + * Signature: init(config, userConsent) + */ +function init(rtdConfig, userConsent) { + // eslint-disable-next-line no-console + console.log('DATAMAGE: init() called with config:', rtdConfig, 'consent:', userConsent); + + const params = (rtdConfig && rtdConfig.params) || {}; + if (!params.api_key) { + logWarn('DataMage: Missing required param "api_key". Requests may fail.'); + } + return true; +} + +function asStringArray(v) { + if (v == null) return []; + if (Array.isArray(v)) return v.map((x) => String(x)); + return [String(v)]; +} + +function ensureSiteContentData(globalOrtb2) { + if (!globalOrtb2.site) globalOrtb2.site = {}; + if (!globalOrtb2.site.content) globalOrtb2.site.content = {}; + if (!Array.isArray(globalOrtb2.site.content.data)) globalOrtb2.site.content.data = []; + return globalOrtb2.site.content.data; +} + +/** + * Build ORTB segment list from iab_cat_ids (+ optional parallel iab_cats names). + */ +function buildSegments(iabCatIds, iabCats) { + const ids = asStringArray(iabCatIds); + const names = Array.isArray(iabCats) ? iabCats.map((x) => String(x)) : []; + return ids.map((id, idx) => { + const seg = { id }; + if (names[idx]) seg.name = names[idx]; + return seg; + }); +} + +function padBase64(b64) { + const mod = b64.length % 4; + return mod ? (b64 + '='.repeat(4 - mod)) : b64; +} + +function stripPortFromUrl(urlStr) { + try { + const u = new URL(urlStr); + if (u.port) u.port = ''; + return u.toString(); + } catch (e) { + return urlStr; + } +} + +/** + * Convert RTD values into GPT-compatible arrays: + * - array -> array of strings + * - comma-string -> split into array + * - scalar -> [scalar] + * - empty string/null -> [] + */ +function toGptArray(v) { + if (v == null) return []; + if (Array.isArray(v)) return v.map((x) => String(x)).filter(Boolean); + + const s = String(v); + if (!s) return []; + if (s.includes(',')) return s.split(',').map((x) => x.trim()).filter(Boolean); + return [s]; +} + +/** + * Publish GPT targeting for direct googletag.pubads().setTargeting() usage on the page. + * This bypasses Prebid's targetingControls allowlist and works even when there are no bids. + */ +function publishForGpt(targetingObj) { + if (typeof window === 'undefined' || !targetingObj) return; + + const gptMap = {}; + Object.keys(targetingObj).forEach((k) => { + gptMap[k] = toGptArray(targetingObj[k]); + }); + + window.__DATAMAGE_GPT_TARGETING__ = gptMap; + + try { + window.dispatchEvent(new CustomEvent('datamage:gptTargeting', { detail: gptMap })); + } catch (e) { + // ignore + } +} + +/** + * RTD getBidRequestData() + * Signature: (reqBidsConfigObj, callback, rtdConfig, userConsent) + */ +function getBidRequestData(reqBidsConfigObj, callback, rtdConfig, userConsent) { + // eslint-disable-next-line no-console + console.log('DATAMAGE: getBidRequestData() triggered. Starting fetch...'); + + try { + if (!reqBidsConfigObj || !reqBidsConfigObj.ortb2Fragments || !reqBidsConfigObj.ortb2Fragments.global) { + logWarn('DataMage: Missing reqBidsConfigObj.ortb2Fragments.global; cannot inject ortb2 for this auction.'); + callback(); + return; + } + + const params = (rtdConfig && rtdConfig.params) || {}; + const selector = params.selector || ''; + const apiKey = params.api_key || ''; + + const auctionTimeoutMs = Number(params.auction_timeout_ms ?? 0); + const fetchTimeoutMs = Number(params.fetch_timeout_ms ?? 2500); + + reqBidsConfigObj.auctionId = reqBidsConfigObj.auctionId || generateUUID(); + + const rawPageUrl = + (typeof window !== 'undefined' && window.location && window.location.href) + ? window.location.href + : ''; + + const pageUrl = stripPortFromUrl(rawPageUrl); + + let encodedUrl = ''; + try { + encodedUrl = padBase64(btoa(pageUrl)); + } catch (e) { + logError('DataMage: URL encoding failed', e); + callback(); + return; + } + + const apiUrl = + `https://opsmage-api.io/context/v3/get?api_key=${encodeURIComponent(apiKey)}` + + `&content_id=${encodedUrl}` + + `&prebid=true` + + `&selector=${encodeURIComponent(selector)}`; + + let callbackFired = false; + const fireCallbackOnce = () => { + if (!callbackFired) { + callbackFired = true; + callback(); + } + }; + + const auctionTimer = setTimeout(fireCallbackOnce, auctionTimeoutMs); + + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const fetchTimer = controller ? setTimeout(() => controller.abort(), fetchTimeoutMs) : null; + + fetch(apiUrl, controller ? { signal: controller.signal } : undefined) + .then(async (response) => { + if (!response.ok) { + try { await response.text(); } catch (e) { /* ignore */ } + logWarn(`DataMage: No processed content (HTTP ${response.status}). Skipping injection.`); + return null; + } + return response.json(); + }) + .then((resJson) => { + if (!resJson) return; + + // eslint-disable-next-line no-console + console.log('DATAMAGE: API Response received', resJson); + + const cc = resJson.content_classification; + if (!cc) { + logWarn('DataMage: 2xx response but missing content_classification. Skipping injection.'); + return; + } + + const iabCats = asStringArray(cc.iab_cats); + const iabCatIds = asStringArray(cc.iab_cat_ids); + + const brandIds = asStringArray(cc.brand_ids); + + const sentimentIds = asStringArray(cc.sentiment_ids); + + const locationIds = asStringArray(cc.location_ids); + + const publicFigureIds = asStringArray(cc.public_figure_ids); + + const restrictedCats = asStringArray(cc.restricted_cats); + const restrictedCatIds = asStringArray(cc.restricted_cat_ids); + + const opsMageDataId = cc.ops_mage_data_id == null ? '' : String(cc.ops_mage_data_id); + const resScore = cc.res_score; + const resScoreBucket = cc.res_score_bucket == null ? '' : String(cc.res_score_bucket); + + // ---- ORTB injection (for bidders) ---- + const ortbContentDataObj = { + name: 'data-mage.com', + segment: buildSegments(iabCatIds, iabCats), + ext: { + ops_mage_data_id: opsMageDataId, + + brand_ids: brandIds, + + sentiment_ids: sentimentIds, + + location_ids: locationIds, + + public_figure_ids: publicFigureIds, + + restricted_cat_ids: restrictedCatIds, + restricted_cats: restrictedCats, + + res_score: resScore, + res_score_bucket: resScoreBucket + } + }; + + ensureSiteContentData(reqBidsConfigObj.ortb2Fragments.global).push(ortbContentDataObj); + + // ---- Targeting object (canonical, array-based) ---- + const targetingArrays = { + om_iab_cat_ids: iabCatIds, + om_iab_cats: iabCats, + + om_brand_ids: brandIds, + + om_sentiment_ids: sentimentIds, + + om_location_ids: locationIds, + + om_public_figure_ids: publicFigureIds, + + om_restricted_cat_ids: restrictedCatIds, + + om_ops_mage_data_id: opsMageDataId, + om_res_score_bucket: resScoreBucket + }; + + if (resScore != null) { + targetingArrays.om_res_score = String(resScore); + } + + publishForGpt(targetingArrays); + + // ---- Legacy string-joined map for getTargetingData() ---- + const join = (arr) => (Array.isArray(arr) ? arr.join(',') : (arr == null ? '' : String(arr))); + lastTargeting = { + om_iab_cat_ids: join(iabCatIds), + om_iab_cats: join(iabCats), + + om_brand_ids: join(brandIds), + + om_sentiment_ids: join(sentimentIds), + + om_location_ids: join(locationIds), + + om_public_figure_ids: join(publicFigureIds), + + om_restricted_cat_ids: join(restrictedCatIds), + + om_ops_mage_data_id: opsMageDataId, + om_res_score_bucket: resScoreBucket + }; + + if (resScore != null) { + lastTargeting.om_res_score = String(resScore); + } + }) + .catch((error) => { + if (error && error.name === 'AbortError') { + logWarn(`DataMage: fetch aborted after ${fetchTimeoutMs}ms (fetch_timeout_ms).`); + } else { + logError('DataMage: Fetch error', error); + } + }) + .finally(() => { + if (fetchTimer) clearTimeout(fetchTimer); + clearTimeout(auctionTimer); + fireCallbackOnce(); + }); + } catch (e) { + logError('DataMage: Unexpected error in getBidRequestData()', e); + callback(); + } +} + +/** + * RTD getTargetingData() + * Signature: (adUnitArray, rtdConfig, userConsent) + */ +function getTargetingData(adUnitArray, rtdConfig, userConsent) { + if (!lastTargeting) return {}; + + const out = {}; + (adUnitArray || []).forEach((au) => { + const code = au && au.code; + if (!code) return; + out[code] = { ...lastTargeting }; + }); + return out; +} + +export const datamageRtdSubmodule = { + name: MODULE_NAME, + init, + getBidRequestData, + getTargetingData, + _resetForTest +}; + +submodule('realTimeData', datamageRtdSubmodule); diff --git a/test/spec/modules/datamageRtdProvider_spec.js b/test/spec/modules/datamageRtdProvider_spec.js new file mode 100644 index 0000000000..f7cdd96fff --- /dev/null +++ b/test/spec/modules/datamageRtdProvider_spec.js @@ -0,0 +1,245 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { datamageRtdSubmodule } from 'modules/datamageRtdProvider.js'; + +describe('datamageRtdSubmodule (DataMage RTD Provider)', function () { + let sandbox; + let fetchStub; + let btoaStub; + + function makeReqBidsConfigObj() { + return { + auctionId: 'auction-1', + ortb2Fragments: { global: {} } + }; + } + + function makeProcessedResponse(overrides = {}) { + return { + content_classification: { + ops_mage_data_id: '7d54b2d30a4e441a0f698dfae8f5b1b5', + res_score: 1, + res_score_bucket: 'high', + + iab_cats: [ + 'Technology & Computing', + 'Technology & Computing|Artificial Intelligence', + 'Business & Finance' + ], + iab_cat_ids: ['596', '597', '52'], + + brand_ids: ['eefd8446', 'b78b9ee2'], + + sentiment_ids: ['95487831', '92bfd7eb'], + + location_ids: ['60efc224'], + + public_figure_ids: ['55eefb4a'], + + restricted_cat_ids: [], + + ...overrides + } + }; + } + + beforeEach(function () { + sandbox = sinon.createSandbox(); + + // reset module state + datamageRtdSubmodule._resetForTest(); + + // cleanup published globals + delete window.__DATAMAGE_GPT_TARGETING__; + + // stub fetch + fetchStub = sandbox.stub(window, 'fetch'); + + // keep tests deterministic + allow port-strip assertion via captured arg + btoaStub = sandbox.stub(window, 'btoa').callsFake((s) => `b64(${s})`); + }); + + afterEach(function () { + sandbox.restore(); + delete window.__DATAMAGE_GPT_TARGETING__; + }); + + it('should return true (enable submodule)', function () { + const ok = datamageRtdSubmodule.init({ name: 'datamage', params: { api_key: 'x' } }, {}); + expect(ok).to.equal(true); + }); + + describe('getBidRequestData()', function () { + it('should call callback quickly (auction_timeout_ms=0) and still inject + publish when fetch resolves later', function (done) { + const req = makeReqBidsConfigObj(); + + // Make fetch resolve "later" + let resolveFetch; + fetchStub.returns(new Promise((resolve) => { resolveFetch = resolve; })); + + const rtdConfig = { + name: 'datamage', + params: { + api_key: 'k', + selector: 'article', + auction_timeout_ms: 0, + fetch_timeout_ms: 2500 + } + }; + + datamageRtdSubmodule.getBidRequestData(req, () => { + // Resolve fetch after callback fires + resolveFetch({ + ok: true, + status: 200, + json: () => Promise.resolve(makeProcessedResponse()), + text: () => Promise.resolve('') + }); + + setTimeout(() => { + // ORTB2 fragments injected for bidders + expect(req.ortb2Fragments.global).to.have.nested.property('site.content.data'); + const dataArr = req.ortb2Fragments.global.site.content.data; + expect(dataArr).to.be.an('array').with.length.greaterThan(0); + expect(dataArr[0]).to.have.property('name', 'data-mage.com'); + expect(dataArr[0]).to.have.property('segment'); + expect(dataArr[0].segment).to.deep.include({ id: '596', name: 'Technology & Computing' }); + + // GPT targeting published (array format) + expect(window.__DATAMAGE_GPT_TARGETING__).to.be.an('object'); + expect(window.__DATAMAGE_GPT_TARGETING__.om_iab_cat_ids).to.deep.equal(['596', '597', '52']); + expect(window.__DATAMAGE_GPT_TARGETING__.om_brand_ids).to.deep.equal(['eefd8446', 'b78b9ee2']); + expect(window.__DATAMAGE_GPT_TARGETING__.om_res_score).to.deep.equal(['1']); + + // publisher domain removed + expect(window.__DATAMAGE_GPT_TARGETING__).to.not.have.property('om_publisher_domain'); + + done(); + }, 0); + }, rtdConfig, {}); + }); + + it('should NOT inject or publish after non-2xx response', function (done) { + const req = makeReqBidsConfigObj(); + + fetchStub.resolves({ + ok: false, + status: 404, + json: () => Promise.resolve({}), + text: () => Promise.resolve('not found') + }); + + datamageRtdSubmodule.getBidRequestData(req, () => { + setTimeout(() => { + expect(req.ortb2Fragments.global).to.not.have.nested.property('site.content.data'); + expect(window.__DATAMAGE_GPT_TARGETING__).to.equal(undefined); + done(); + }, 0); + }, { name: 'datamage', params: { api_key: 'k', auction_timeout_ms: 0, fetch_timeout_ms: 50 } }, {}); + }); + + it('should not include om_res_score when res_score is null (2xx)', function (done) { + const req = makeReqBidsConfigObj(); + + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(makeProcessedResponse({ res_score: null })), + text: () => Promise.resolve('') + }); + + datamageRtdSubmodule.getBidRequestData(req, () => { + setTimeout(() => { + expect(window.__DATAMAGE_GPT_TARGETING__).to.be.an('object'); + expect(window.__DATAMAGE_GPT_TARGETING__).to.not.have.property('om_res_score'); + done(); + }, 0); + }, { name: 'datamage', params: { api_key: 'k', auction_timeout_ms: 0, fetch_timeout_ms: 50 } }, {}); + }); + + it('should strip port from URL before encoding', function (done) { + const req = makeReqBidsConfigObj(); + + fetchStub.resolves({ + ok: false, + status: 404, + json: () => Promise.resolve({}), + text: () => Promise.resolve('not found') + }); + + datamageRtdSubmodule.getBidRequestData( + req, + () => { + try { + expect(btoaStub.called).to.equal(true); + const btoaArg = btoaStub.firstCall.args[0]; + + expect(btoaArg).to.be.a('string'); + expect(btoaArg).to.not.match(/\/\/[^/]+:\d+\//); + + done(); + } catch (e) { + done(e); + } + }, + { name: 'datamage', params: { api_key: 'k', auction_timeout_ms: 0, fetch_timeout_ms: 50 } }, + {} + ); + }); + }); + + describe('getTargetingData()', function () { + it('should return {} if no successful 2xx fetch has happened yet', function () { + const out = datamageRtdSubmodule.getTargetingData([{ code: 'div-1' }], {}, {}); + expect(out).to.deep.equal({}); + }); + + it('should return per-adunit targeting (string-joined lists) after 2xx response resolves', function (done) { + const req = makeReqBidsConfigObj(); + + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(makeProcessedResponse()), + text: () => Promise.resolve('') + }); + + datamageRtdSubmodule.getBidRequestData(req, () => { + setTimeout(() => { + const out = datamageRtdSubmodule.getTargetingData([{ code: 'div-1' }, { code: 'div-2' }], {}, {}); + expect(out).to.have.property('div-1'); + expect(out).to.have.property('div-2'); + + expect(out['div-1']).to.have.property('om_iab_cat_ids', '596,597,52'); + expect(out['div-1']).to.have.property('om_brand_ids', 'eefd8446,b78b9ee2'); + expect(out['div-1']).to.have.property('om_res_score', '1'); + + // publisher domain removed + expect(out['div-1']).to.not.have.property('om_publisher_domain'); + + done(); + }, 0); + }, { name: 'datamage', params: { api_key: 'k', auction_timeout_ms: 0, fetch_timeout_ms: 50 } }, {}); + }); + + it('should ignore ad units missing code', function (done) { + const req = makeReqBidsConfigObj(); + + fetchStub.resolves({ + ok: true, + status: 200, + json: () => Promise.resolve(makeProcessedResponse()), + text: () => Promise.resolve('') + }); + + datamageRtdSubmodule.getBidRequestData(req, () => { + setTimeout(() => { + const out = datamageRtdSubmodule.getTargetingData([{ code: 'div-1' }, {}, { code: null }], {}, {}); + expect(out).to.have.property('div-1'); + expect(Object.keys(out)).to.deep.equal(['div-1']); + done(); + }, 0); + }, { name: 'datamage', params: { api_key: 'k', auction_timeout_ms: 0, fetch_timeout_ms: 50 } }, {}); + }); + }); +}); diff --git a/test_datamage.html b/test_datamage.html new file mode 100644 index 0000000000..c88263caa9 --- /dev/null +++ b/test_datamage.html @@ -0,0 +1,208 @@ + + + + + + OpsMage Prebid Test Page + + + + + + + + + + + + +

OpsMage Prebid Test Page

+
+
This is the content being classified.
+ + + \ No newline at end of file From 035b2a6920098824c03fd5906c3dd5e0687485c1 Mon Sep 17 00:00:00 2001 From: leiforion Date: Wed, 18 Feb 2026 15:25:01 +0400 Subject: [PATCH 2/3] remove test.html files --- test_datamage.html | 208 --------------------------------------------- 1 file changed, 208 deletions(-) delete mode 100644 test_datamage.html diff --git a/test_datamage.html b/test_datamage.html deleted file mode 100644 index c88263caa9..0000000000 --- a/test_datamage.html +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - OpsMage Prebid Test Page - - - - - - - - - - - - -

OpsMage Prebid Test Page

-
-
This is the content being classified.
- - - \ No newline at end of file From 67456d8f4b0c317f78d27120e557b2a7f50e15fd Mon Sep 17 00:00:00 2001 From: leiforion Date: Wed, 18 Feb 2026 17:04:00 +0400 Subject: [PATCH 3/3] update example.html --- .../datamageRtdProvider_example.html | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 integrationExamples/realTimeData/datamageRtdProvider_example.html diff --git a/integrationExamples/realTimeData/datamageRtdProvider_example.html b/integrationExamples/realTimeData/datamageRtdProvider_example.html new file mode 100644 index 0000000000..9e9a38b62c --- /dev/null +++ b/integrationExamples/realTimeData/datamageRtdProvider_example.html @@ -0,0 +1,213 @@ + + + + + + OpsMage Prebid Test Page + + + + + + + + + + + + +

OpsMage Prebid Test Page

+
+
The tech world is currently buzzing over the highly anticipated market debut of fakeDSP, a trailblazing + startup poised to redefine the landscape of digital signal processing. Leveraging proprietary neuromorphic + algorithms and quantum-ready architecture, fakeDSP promises to accelerate real-time data synthesis by speeds + previously thought impossible. With early analysts calling it a definitive disruptor in both the + telecommunications and audio-engineering sectors, the company’s entrance signifies a major leap forward in how + complex signals are analyzed and reconstructed in the AI era.
+ + + \ No newline at end of file