lipdjs
Version:
A JavaScript library for reading and writing LiPD (Linked Paleo Data) files
906 lines (800 loc) • 35.6 kB
text/typescript
import { Store, Writer } from 'n3';
import * as fs from 'fs';
import * as path from 'path';
import JSZip from 'jszip';
import { isBrowser } from './utils/env';
import { Logger } from './utils/logger';
import { DEFAULT_GRAPH_URI, NSURL } from './globals/urls';
import { RDFGraph, AuthCredentials } from './rdfGraph';
import { RDFToLiPD } from './utils/rdfToLipd';
import { LipdToRDF } from './utils/lipdToRdf';
import { LiPDSeries } from './lipdSeries';
import {
QUERY_DSNAME,
QUERY_DSID,
QUERY_UNIQUE_ARCHIVE_TYPE,
QUERY_FILTER_DATASET_NAME,
QUERY_FILTER_COMPILATION,
QUERY_FILTER_TIME
} from './globals/queries';
import { multiLoadLipd } from './utils/multiProcessing';
import { sanitizeId, serializeStore } from './utils/utils';
import { Dataset } from './classes/dataset';
import { RDFToJSON } from './utils/rdfToJson';
import { JSONToRDF } from './utils/jsonToRdf';
import { v4 as uuidv4 } from 'uuid';
import * as pako from 'pako';
const logger = Logger.getInstance();
export class LiPD extends RDFGraph {
/**
* The LiPD class describes a LiPD (Linked Paleo Data) object. It contains an RDF Graph which is serialization
* of the LiPD data into an RDF graph containing terms from the LiPD Ontology.
* @param store Optional N3 store to initialize with
* @param quiet Whether to suppress log messages
* @param endpoint Optional SPARQL endpoint URL
* @param auth Optional authentication credentials for the SPARQL endpoint
*/
constructor(store?: Store, quiet: boolean = false, endpoint?: string, auth?: AuthCredentials) {
super(store, quiet, endpoint, auth);
}
/**
* Load LiPD files from a directory
* @param dirPath Path to the directory containing LiPD files
* @param standardize Whether to standardize the data
* @param addLabels Whether to add labels
*/
public async loadFromDir(dirPath: string, standardize: boolean = true, addLabels: boolean = true): Promise<void> {
if (!fs.existsSync(dirPath)) {
throw new Error(`Directory ${dirPath} does not exist`);
}
const lipdFiles: string[] = [];
const files = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
if (fs.statSync(filePath).isFile() && file.endsWith('.lpd')) {
lipdFiles.push(filePath);
}
}
await this.load(lipdFiles, standardize, addLabels);
}
/**
* Load LiPD files
* @param lipdFiles Array of paths to LiPD files (can also be URLs)
* @param standardize Whether to standardize the data
* @param addLabels Whether to add labels
*/
public async load(lipdFiles: string | string[], standardize: boolean = true, addLabels: boolean = true): Promise<void> {
logger.debug('Loading LiPD files...' + lipdFiles);
const files = Array.isArray(lipdFiles) ? lipdFiles : [lipdFiles];
const numFiles = files.length;
if (!this.quiet) {
logger.debug(`Loading ${numFiles} LiPD files`);
}
this.store = await multiLoadLipd(this.store, files, true, standardize, addLabels);
logger.debug('Multi-loading done');
logger.debug(`Number of quads in LiPD: ${this.store.size}`);
if (!this.quiet) {
logger.debug('Loaded..');
}
}
/**
* Load LiPD file from a File object (for browser file input)
* @param file File object from HTML5 file input
* @param standardize Whether to standardize the data
* @param addLabels Whether to add labels
*/
public async loadFromFile(file: File, standardize: boolean = true, addLabels: boolean = true): Promise<void> {
logger.debug('Loading LiPD file from File object: %s', file.name);
if (!this.quiet) {
logger.debug(`Loading LiPD file: ${file.name}`);
}
const converter = new LipdToRDF(standardize, addLabels);
await converter.loadFromFile(file);
// Merge the converted data into our store
const quads = converter.store.getQuads(null, null, null, null);
for (const quad of quads) {
if (this.store.getQuads(quad.subject, quad.predicate, quad.object, quad.graph).length === 0) {
this.store.addQuad(quad);
}
}
logger.debug(`Number of quads in LiPD: ${this.store.size}`);
if (!this.quiet) {
logger.debug('File loaded successfully');
}
}
/**
* Get LiPD JSON for a dataset
* @param dsname Dataset ID
* @returns LiPD JSON
*/
public getLipd(dsname: string): any {
const converter = new RDFToLiPD(this.store);
return converter.convertToJson(dsname);
}
/**
* Create LiPD file for a dataset
* @param dsname Dataset ID
* @param lipdFile Path to LiPD file
* @returns LiPD JSON
*/
public async createLipd(dsname: string, lipdFile: string): Promise<any> {
const converter = new RDFToLiPD(this.store);
const lipdJson = await converter.convert(dsname, lipdFile);
// Remove values from variables before creating LiPD file
// Values should be stored in CSV files, not in metadata.jsonld
return this._removeValuesFromVariables(lipdJson);
}
/**
* Get dataset(s) from the graph and returns the popped LiPD object
* @param dsnames Dataset name(s) to get
* @returns LiPD object with the retrieved dataset(s)
*/
public get(dsnames: string | string[]): LiPD {
const names = Array.isArray(dsnames) ? dsnames : [dsnames];
const dsids = names.map(name =>
name.startsWith(NSURL) ? name : `${NSURL}/${name}`
);
const ds = super.get(dsids);
return new LiPD(ds.getStore(), this.quiet, this.getEndpoint(), this.auth);
}
/**
* Pop dataset(s) from the graph and returns the popped LiPD object
* @param dsnames Dataset name(s) to be popped
* @returns LiPD object with the popped dataset(s)
*/
public pop(dsnames: string | string[]): LiPD {
const names = Array.isArray(dsnames) ? dsnames : [dsnames];
const dsids = names.map(name =>
name.startsWith(NSURL) ? name : `${NSURL}/${name}`
);
const popped = super.pop(dsids);
return new LiPD(popped.getStore(), this.quiet, this.getEndpoint(), this.auth);
}
/**
* Remove dataset(s) from the graph
* @param dsnames Dataset name(s) to be removed
*/
public remove(dsnames: string | string[]): void {
const names = Array.isArray(dsnames) ? dsnames : [dsnames];
const dsids = names.map(name =>
name.startsWith(NSURL) ? name : `${NSURL}/${name}`
);
super.remove(dsids);
}
/**
* Get all dataset names
* @returns List of dataset names
*/
public async getAllDatasetNames(): Promise<string[]> {
const [qres] = await this.query(QUERY_DSNAME);
return qres.map((row: { dsname: any }) => sanitizeId(row.dsname.value));
}
/**
* Get all dataset IDs
* @returns List of dataset IDs
*/
public async getAllDatasetIds(): Promise<string[]> {
const [qres] = await this.query(QUERY_DSID);
return qres.map((row: { dsid: string }) => sanitizeId(row.dsid));
}
/**
* Get all archive types
* @returns List of archive types
*/
public async getAllArchiveTypes(): Promise<string[]> {
const [qres] = await this.query(QUERY_UNIQUE_ARCHIVE_TYPE);
return qres.map((row: { archiveType: string }) => String(row.archiveType));
}
/**
* Get all datasets as Dataset class instances
* @returns List of Dataset objects
*
* @example
* ```typescript
* const lipd = new LiPD();
* lipd.load('path/to/file.lpd').then(() => {
* lipd.getDatasets().then(datasets => {
* // Work with dataset objects
* console.log(datasets[0].getName());
* });
* });
* ```
*/
public async getDatasets(): Promise<Dataset[]> {
const datasets: Dataset[] = [];
const datasetNames = await this.getAllDatasetNames();
for (const dsname of datasetNames) {
let dsuri = NSURL + "/" + dsname
let r2j = new RDFToJSON(dsuri, this.store)
let data = JSON.parse(r2j.toJson())
let ds = Dataset.fromData(dsuri, data)
// Order variables according to column number
for (const pd of ds.getPaleoData()) {
for (const table of pd.getMeasurementTables()) {
table.variables = table.variables.sort((a: any, b: any) => (a.columnNumber ?? 0) - (b.columnNumber ?? 0));
}
}
datasets.push(ds)
}
return datasets;
}
/**
* Loads instances of Dataset class into the LiPD graph
* @param datasets List of Dataset objects
*
* @example
* ```typescript
* // Load datasets from one LiPD object to another
* const lipd1 = new LiPD();
* lipd1.load('path/to/file.lpd').then(() => {
* lipd1.getDatasets().then(datasets => {
* // Modify datasets if needed
*
* // Create a new LiPD instance and load the datasets
* const lipd2 = new LiPD();
* lipd2.loadDatasets(datasets);
* });
* });
* ```
*/
public loadDatasets(datasets: Dataset[]): void {
for (const ds of datasets) {
this._fixMissingIds(ds);
const dsuri = ds.getId() || NSURL + "/" + ds.getName();
const j2r = new JSONToRDF(this.store, dsuri);
j2r.loadJson(ds.toData());
}
}
/**
* Generate a unique ID with a given prefix
* @param prefix Prefix for the ID (default: 'PYD')
* @returns Unique formatted ID
* @private
*/
private _generateUniqueId(prefix: string = 'PYD'): string {
// Generate a random UUID
const randomUuid = uuidv4();
// Convert UUID format to the specific format we need
const idStr = randomUuid;
const formattedId = `${prefix}-${idStr.substring(0, 5)}-${idStr.substring(9, 13)}-${idStr.substring(14, 18)}-${idStr.substring(19, 23)}-${idStr.substring(24, 28)}`;
return formattedId;
}
/**
* Fix missing IDs in a dataset
* @param ds Dataset to fix
* @private
*/
private _fixMissingIds(ds: Dataset): void {
// Assign variable ids if not present
// Assign datatable csv file name if not present
let pdCounter = 0;
for (const pd of ds.getPaleoData()) {
let tableCounter = 0;
for (const table of pd.getMeasurementTables()) {
if (!table.getFileName()) {
table.setFileName(`paleo${pdCounter}measurement${tableCounter}.csv`);
}
for (const v of table.getVariables()) {
if (!v.getVariableId()) {
v.setVariableId(this._generateUniqueId('TS'));
}
}
tableCounter++;
}
pdCounter++;
}
let chronCounter = 0;
for (const chron of ds.getChronData()) {
let tableCounter = 0;
for (const table of chron.getMeasurementTables()) {
if (!table.getFileName()) {
table.setFileName(`chron${chronCounter}measurement${tableCounter}.csv`);
}
for (const v of table.getVariables()) {
if (!v.getVariableId()) {
v.setVariableId(this._generateUniqueId('TS'));
}
}
tableCounter++;
}
let modelCounter = 0;
for (const model of chron.getModeledBy()) {
let tableCounter = 0;
for (const table of model.getEnsembleTables()) {
if (!table.getFileName()) {
table.setFileName(`chron${chronCounter}model${modelCounter}ensemble${tableCounter}.csv`);
}
for (const v of table.getVariables()) {
if (!v.getVariableId()) {
v.setVariableId(this._generateUniqueId('TS'));
}
}
tableCounter++;
}
modelCounter++;
}
chronCounter++;
}
}
/**
* Convert the LiPD object to a LiPDSeries object
* @returns LiPDSeries object
*/
public toLipdSeries(): LiPDSeries {
const series = new LiPDSeries();
series.load(this);
return series;
}
/**
* Filter datasets by name
* @param datasetName Dataset name to filter by
* @returns New LiPD object with filtered datasets
*/
public async filterByDatasetName(datasetName: string): Promise<LiPD> {
const query = QUERY_FILTER_DATASET_NAME.replace('[datasetName]', datasetName);
const [qres] = await this.query(query);
const dsnames = qres.map((row: { dsname: string }) => sanitizeId(row.dsname));
return this.get(dsnames);
}
/**
* Filter datasets by compilation name
* @param compilationName Compilation name to filter by
* @returns New LiPD object with filtered datasets
*/
public async filterByCompilationName(compilationName: string): Promise<LiPD> {
const query = QUERY_FILTER_COMPILATION.replace('[compilationName]', compilationName);
const [qres] = await this.query(query);
const dsnames = qres.map((row: { dataSetName: string }) => sanitizeId(row.dataSetName));
return this.get(dsnames);
}
public async serialize(type: string = 'turtle'): Promise<string> {
return await serializeStore(this.store, type, logger);
}
/**
* Filter datasets by time interval
* @param timeBound Minimum and maximum age values
* @param timeBoundType Type of querying to perform
* @param recordLength Minimum record length
* @returns New LiPD object with filtered datasets
*/
public async filterByTime(
timeBound: [number, number],
timeBoundType: 'any' | 'entire' | 'entirely' = 'any',
recordLength?: number
): Promise<LiPD> {
if (timeBound[0] > timeBound[1]) {
timeBound = [timeBound[1], timeBound[0]];
}
const query = QUERY_FILTER_TIME;
const [, df] = await this.query(query);
let filterDf;
if (recordLength === undefined) {
switch (timeBoundType) {
case 'entirely':
filterDf = df.filter((row: { minage: number; maxage: number }) =>
row.minage <= timeBound[0] && row.maxage >= timeBound[1]
);
break;
case 'entire':
filterDf = df.filter((row: { minage: number; maxage: number }) =>
row.minage >= timeBound[0] && row.maxage <= timeBound[1]
);
break;
case 'any':
filterDf = df.filter((row: { minage: number }) => row.minage <= timeBound[1]);
break;
default:
throw new Error("timeBoundType must be in ['any', 'entirely', 'entire']");
}
} else {
switch (timeBoundType) {
case 'entirely':
filterDf = df.filter((row: { minage: number; maxage: number }) =>
row.minage <= timeBound[0] &&
row.maxage >= timeBound[1] &&
Math.abs(row.maxage - row.minage) >= recordLength
);
break;
case 'entire':
filterDf = df.filter((row: { minage: number; maxage: number }) =>
row.minage >= timeBound[0] &&
row.maxage <= timeBound[1] &&
Math.abs(row.maxage - row.minage) >= recordLength
);
break;
case 'any':
filterDf = df.filter((row: { minage: number }) =>
row.minage <= timeBound[1] &&
Math.abs(row.minage - timeBound[1]) >= recordLength
);
break;
default:
throw new Error("timeBoundType must be in ['any', 'entirely', 'entire']");
}
}
const dsnames = filterDf.map((row: { dsname: string }) => row.dsname);
return this.get(dsnames);
}
/**
* Updates local LiPD Graph for datasets to remote endpoint
* @param dsnames Array of dataset names
* @param batchSize Number of quads to include in each update batch (default: 100)
*
* @example
* ```typescript
* // Update datasets to remote endpoint
* const lipd = new LiPD();
* lipd.setEndpoint("https://linkedearth.graphdb.mint.isi.edu/repositories/LiPDVerse-dynamic");
* // Set authentication if needed
* lipd.setAuth({ username: "user", password: "pass" });
* lipd.updateRemoteDatasets(["MyDataset1", "MyDataset2"], 100);
* ```
*/
public async updateRemoteDatasets(dsnames: string | string[], batchSize: number = 100): Promise<void> { // batchSize ignored in bulk loader
if (!this.endpoint) {
throw new Error("No remote endpoint set");
}
const namesList = Array.isArray(dsnames) ? dsnames : [dsnames];
if (namesList.length === 0) {
throw new Error("No dataset names provided");
}
for (const dsname of namesList) {
const graphUri = `${NSURL}/${dsname}`;
const backupGraphUri = `${graphUri}_backup_${Date.now()}`;
try {
// ---------------------------------------------
// 1. Backup remote graph if it exists
// ---------------------------------------------
this.setRemote(true);
const graphExists = await this.askQuery(`ASK WHERE { GRAPH <${graphUri}> { ?s ?p ?o } }`);
if (graphExists) {
await this.updateQuery(`COPY GRAPH <${graphUri}> TO GRAPH <${backupGraphUri}>`);
}
// ---------------------------------------------
// 2. Extract local quads and serialise to N-Quads
// ---------------------------------------------
this.setRemote(false);
const quads = this.store.getQuads(null, null, null, graphUri);
if (quads.length === 0) {
logger.debug(`No quads found for dataset: ${dsname}`);
continue;
}
const nqData = await this._quadsToNQuads(quads);
// ---------------------------------------------
// 3. Clear remote graph (if existed) and bulk upload
// ---------------------------------------------
this.setRemote(true);
if (graphExists) {
await this.updateQuery(`CLEAR GRAPH <${graphUri}>`);
}
// Prepare request
let body: Uint8Array | string = nqData;
const headers: Record<string, string> = {
'Content-Type': 'application/n-quads'
};
// gzip if sizable (>10 KB)
if (nqData.length > 10_000) {
body = pako.gzip(nqData);
headers['Content-Encoding'] = 'gzip';
}
if (this.auth) {
const authStr = Buffer.from(`${this.auth.username}:${this.auth.password}`).toString('base64');
headers['Authorization'] = `Basic ${authStr}`;
}
const statementsEndpoint = this._getStatementsEndpoint();
const url = `${statementsEndpoint}?context=${encodeURIComponent(`<${graphUri}>`)}`;
const response = await fetch(url, {
method: 'POST',
headers: headers as any,
body: body as any
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error from bulk loader (${response.status}): ${errorText}`);
}
// ---------------------------------------------
// 4. Remove backup on success
// ---------------------------------------------
if (graphExists) {
await this.updateQuery(`DROP GRAPH <${backupGraphUri}>`);
}
} catch (err) {
logger.error(`Failed to update remote graph for ${dsname}: ${err}`);
// Attempt rollback from backup
try {
this.setRemote(true);
const backupExists = await this.askQuery(`ASK WHERE { GRAPH <${backupGraphUri}> { ?s ?p ?o } }`);
if (backupExists) {
await this.updateQuery(`COPY GRAPH <${backupGraphUri}> TO GRAPH <${graphUri}>`);
await this.updateQuery(`DROP GRAPH <${backupGraphUri}>`);
}
} catch (restoreErr) {
logger.error(`Failed to restore backup for ${dsname}: ${restoreErr}`);
}
throw err;
} finally {
this.setRemote(false);
}
}
logger.debug("Remote datasets updated successfully");
}
/**
* Convert an array of quads to an N-Quads string
*/
private async _quadsToNQuads(quads: any[]): Promise<string> {
return new Promise((resolve, reject) => {
const writer = new Writer({ format: 'N-Quads' });
writer.addQuads(quads);
writer.end((err, result) => {
if (err) {
reject(err);
} else {
resolve(result as string);
}
});
});
}
/**
* Derive the /statements endpoint from this.endpoint
*/
private _getStatementsEndpoint(): string {
if (!this.endpoint) {
throw new Error("Endpoint not set");
}
return this.endpoint.replace(/\/repositories\/([^/]+)$/, '/repositories/$1/statements');
}
/**
* Builds an INSERT DATA query for a batch of quads
* @param quads Array of quads to insert
* @param graphUri URI of the graph to insert into
* @returns SPARQL INSERT query
* @private
*/
private _buildInsertQuery(quads: any[], graphUri: string): string {
// For debugging, log one of the quads to examine its structure
if (quads.length > 0) {
console.log("Sample quad structure:", JSON.stringify(quads[0], null, 2));
}
// Simpler, more robust approach to building the query
let insertQuery = `INSERT DATA { GRAPH <${graphUri}> {\n`;
for (const quad of quads) {
let subject, predicate, object;
// Handle subject based on term type
if (quad.subject.termType === 'NamedNode') {
subject = `<${quad.subject.value}>`;
} else {
subject = `_:${quad.subject.value}`;
}
// Handle predicate (always a named node)
predicate = `<${quad.predicate.value}>`;
// Handle object based on term type
if (quad.object.termType === 'NamedNode') {
object = `<${quad.object.value}>`;
} else if (quad.object.termType === 'BlankNode') {
object = `_:${quad.object.value}`;
} else {
// For literals, handle special characters and add language/datatype
let literalValue = quad.object.value.toString()
.replace(/\\/g, '\\\\') // escape backslashes first
.replace(/"/g, '\\"') // escape quotes
.replace(/\n/g, '\\n') // escape newlines
.replace(/\r/g, '\\r') // escape carriage returns
.replace(/\t/g, '\\t'); // escape tabs
object = `"${literalValue}"`;
// Add language tag if present
if (quad.object.language) {
object += `@${quad.object.language}`;
}
// Add datatype if present and not plain literal
else if (quad.object.datatype && quad.object.datatype.value !== 'http://www.w3.org/2001/XMLSchema#string') {
object += `^^<${quad.object.datatype.value}>`;
}
}
// Add the triple to the query with proper spacing
insertQuery += ` ${subject} ${predicate} ${object} .\n`;
}
insertQuery += '}}';
// Log a short preview of the query
const previewLength = Math.min(insertQuery.length, 200);
console.log(`Generated query preview (${insertQuery.length} chars): ${insertQuery.substring(0, previewLength)}${insertQuery.length > previewLength ? '...' : ''}`);
return insertQuery;
}
/**
* Loads remote datasets into cache if a remote endpoint is set
* @param dsnames Array of dataset names
* @param loadDefaultGraph Whether to load the default graph (default: true)
*
* @example
* ```typescript
* // Fetch LiPD data from remote RDF Graph
* const lipd = new LiPD();
* lipd.setEndpoint("https://linkedearth.graphdb.mint.isi.edu/repositories/LiPDVerse-dynamic");
* // Set authentication if needed
* lipd.setAuth({ username: "user", password: "pass" });
* lipd.loadRemoteDatasets(["Ocn-MadangLagoonPapuaNewGuinea.Kuhnert.2001", "MD98_2181.Stott.2007"]);
* lipd.getAllDatasetNames().then(names => console.log(names));
* ```
*/
public async loadRemoteDatasets(dsnames: string | string[], loadDefaultGraph: boolean = true): Promise<void> {
if (!this.endpoint) {
throw new Error("No remote endpoint");
}
const namesList = Array.isArray(dsnames) ? dsnames : [dsnames];
if (namesList.length === 0) {
throw new Error("No dataset names to cache");
}
let dsnamestr = namesList.map(dsname => `<${NSURL}/${dsname}>`).join(' ');
if (loadDefaultGraph) {
dsnamestr += ` <${DEFAULT_GRAPH_URI}>`;
}
console.log("Caching datasets from remote endpoint..");
this.setRemote(true);
const [qres] = await this.query(`SELECT ?s ?p ?o ?g WHERE { GRAPH ?g { ?s ?p ?o } VALUES ?g { ${dsnamestr} } }`);
this.setRemote(false);
// Add quads to the store
for (const row of qres) {
this.store.addQuad(row.s, row.p, row.o, row.g);
}
console.log("Done..");
}
/**
* Build a full LiPD (BagIt) archive entirely in-memory – browser-safe
* @param dsname Dataset name to export
* @param opts Options { includeCsv?: boolean }
* @returns Blob in browsers, Buffer in Node
*/
public async createLipdBrowser(dsname: string, opts: { includeCsv?: boolean } = {}): Promise<Blob | Uint8Array> {
const includeCsv = opts.includeCsv !== false;
// 1. Get LiPD JSON for the dataset
const originalLipdJson = this.getLipd(dsname);
if (!originalLipdJson) {
throw new Error(`Dataset ${dsname} not found in LiPD graph`);
}
// 2. Build CSV files in-memory if requested (use original data with values)
const csvMap: Record<string, string> = includeCsv ? this._generateCsvData(originalLipdJson) : {};
// Remove values from variables before creating LiPD file
// Values should be stored in CSV files, not in metadata.jsonld
const lipdJson = this._removeValuesFromVariables(originalLipdJson);
// 3. Assemble BagIt archive with JSZip (use cleaned lipdJson without values)
const zip = new JSZip();
const dataFolder = zip.folder('data')!;
dataFolder.file('metadata.jsonld', JSON.stringify(lipdJson, null, 2));
for (const [name, csv] of Object.entries(csvMap)) {
dataFolder.file(name, csv);
}
// 4. bagit.txt & bag-info.txt
const bagitTxt = 'BagIt-Version: 1.0\nTag-File-Character-Encoding: UTF-8';
zip.file('bagit.txt', bagitTxt);
const bagInfoTxt = `Bagging-Date: ${new Date().toISOString()}\nBag-Software-Agent: lipdjs`;
zip.file('bag-info.txt', bagInfoTxt);
// 5. manifest-sha256.txt – compute hashes for files in data/
const manifestLines: string[] = [];
const encoder = new TextEncoder();
const computeHash = async (content: string): Promise<string> => {
if (isBrowser() && typeof crypto !== 'undefined' && (crypto as any).subtle) {
const buffer = encoder.encode(content);
const digest = await (crypto as any).subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
} else {
// Node fallback (dynamic import to avoid bundling crypto for browser)
const { createHash } = await import('crypto');
return createHash('sha256').update(content).digest('hex');
}
};
// metadata.jsonld first
manifestLines.push(`${await computeHash(JSON.stringify(lipdJson, null, 2))} data/metadata.jsonld`);
// CSVs
for (const [name, csv] of Object.entries(csvMap)) {
manifestLines.push(`${await computeHash(csv)} data/${name}`);
}
zip.file('manifest-sha256.txt', manifestLines.join('\n'));
// 6. Generate zip with compression
const zipOptions = {
compression: 'DEFLATE' as const,
compressionOptions: { level: 6 } // 1=fastest, 9=best compression, 6=balanced
};
if (isBrowser()) {
return await zip.generateAsync({ type: 'blob', ...zipOptions });
}
// Node – return Buffer/Uint8Array
return await zip.generateAsync({ type: 'uint8array', ...zipOptions });
}
// ---------------------------------------------------------------------
// Helper: generate CSV text for all tables in a LiPD JSON object
// ---------------------------------------------------------------------
private _generateCsvData(lipd: any): Record<string, string> {
const csvs: Record<string, string> = {};
const tableKeys: Array<[string, string]> = [
['paleoData', 'measurementTable'],
['chronData', 'measurementTable']
];
for (const [sectionKey, tableKey] of tableKeys) {
const section = lipd[sectionKey];
if (!Array.isArray(section)) continue;
for (const secItem of section) {
if (Array.isArray(secItem[tableKey])) {
for (const table of secItem[tableKey]) {
const { filename, columns } = table;
if (!filename || !columns) continue;
const csvContent = this._tableToCsv(columns);
csvs[filename] = csvContent;
}
}
// Also handle model tables if present (ensembleTable, summaryTable, distributionTable)
if (Array.isArray(secItem.model)) {
for (const model of secItem.model) {
const subTables = ['ensembleTable', 'summaryTable', 'distributionTable'];
for (const key of subTables) {
if (Array.isArray(model[key])) {
for (const table of model[key]) {
const { filename, columns } = table;
if (!filename || !columns) continue;
csvs[filename] = this._tableToCsv(columns);
}
}
}
}
}
}
}
return csvs;
}
private _tableToCsv(columns: any[]): string {
if (!Array.isArray(columns) || columns.length === 0) return '';
const maxLen = Math.max(...columns.map(c => (c.values?.length ?? 0)));
const rows: string[] = [];
for (let i = 0; i < maxLen; i++) {
const row = columns.map(col => (col.values?.[i] ?? ''));
rows.push(row.join(','));
}
return rows.join('\n');
}
/**
* Remove values from variables in a LiPD JSON object.
* This is necessary because values are typically stored in CSV files,
* not directly in the metadata.jsonld file.
* @param lipdJson The LiPD JSON object to process.
* @returns A new LiPD JSON object with values removed from variables.
* @private
*/
private _removeValuesFromVariables(lipdJson: any): any {
if (typeof lipdJson !== 'object' || lipdJson === null) {
return lipdJson;
}
if (Array.isArray(lipdJson)) {
return lipdJson.map(item => this._removeValuesFromVariables(item));
}
if (typeof lipdJson === 'object') {
const newObj: any = {};
for (const key in lipdJson) {
if (Object.prototype.hasOwnProperty.call(lipdJson, key)) {
// Skip the 'values' key if this looks like a variable object
if (key === 'values' && this._isVariableObject(lipdJson)) {
// Skip copying the values key for variable objects
continue;
}
newObj[key] = this._removeValuesFromVariables(lipdJson[key]);
}
}
return newObj;
}
return lipdJson;
}
/**
* Helper to check if an object looks like a variable object.
* Variables typically have properties like variableId, variableName, values, units, etc.
* @param obj The object to check.
* @returns True if it looks like a variable object, false otherwise.
* @private
*/
private _isVariableObject(obj: any): boolean {
if (!obj || typeof obj !== 'object') {
return false;
}
// Check for common variable properties
const variableProps = ['variableId', 'variableName', 'TSid', 'number', 'columnNumber'];
const hasVariableProperty = variableProps.some(prop => prop in obj);
// If it has values and at least one variable property, treat it as a variable
return 'values' in obj && hasVariableProperty;
}
}