From Excel to Turtle With NodeJS
When most people think about the data that an organization has, they would likely envision some big server farm somewhere with terabytes of data in huge relational databases. Yet a surprising (even disturbing) amount of the metadata within that organization likely isn’t stored there, but rather has been created (and maintained) in Microsoft Excel or its many, many derivatives over the years. This is also, typically, information for which semantic triple stores and metadata hubs are ideally suited — controlled vocabularies, data dictionaries, summary analyses and so forth.
There is a problem with Excel as a data source, however. A Excel workbook is in essence a book of worksheets, which are themselves grids. There is no inherit structure there, no real schema, beyond the sometimes obscure designs that the spreadsheet creator assigned themselves in an ad hoc fashion. This means that if you give an Excel spreadsheet to an ontologist or ingestion engineer, there is no magic button that they can press to slurp that structure into place; the structure has to be worked out forensinsically. This means that if you want to use Excel in this fashion, you have to impose such an order ahead of time.
Cell As Triple
From a semantics standpoint, there are two approaches that you can take to convert such spreadsheets into RDF data. In the first, you locate every cell that has content in it, then create a URL to reflect the location of the cell based upon the spreadsheet, row and column.
For instance, if you had a spreadsheet called myData.xls (or similar extension), then the cell [B5] in “My Spreadsheet” (with the value “Jane Doe” might be given as the following triples (given as Turtle):
cell:_myData_MySpreadsheet_Cell_B5
a cell: ;
cell:inRow row:_MyData_MySpreadsheet_Row_5 ;
cell:inColumn column:_MyData_MySpreadsheet_Column_B ;
cell:hasValue "Jane Doe"^^xsd:string ;
cell:hasAddress "B5"^^xsd:string ;
.
row:_MyData_MySpreadsheet_Row_5
a row: ;
row:inSheet sheet:_MyData_MySpreadsheet ;
row:hasAddress: "5"^^xsd:integer ;
.
column:_MyData_MySpreadsheet_Column_B
a column: ;
column:inSheet sheet:_MyData_MySpreadsheet ;
column:hasAddress: "B"^^xsd:string ;
.
sheet:_myData_MySpreadsheet
a sheet: ;
sheet:inWorkbook workbook:_myData ;
.
workbook:_myData
a workbook: ;
workbook:url "https://www.example.com/myData.xlsx" ;
.
...
This is actually not a bad approach when the structure of the spreadsheet is both sparse and very well specified but unless that structure is there and can be sussed out, it’s a lot of work often for little payoff otherwise. Best use cases there are when Excel is used as an input form for data or when it is generated algorithmically (possibly as a report) and the specific structure is mostly known. However, it is often better to take advantage of one of the bigger benefits of spreadsheets — the association between worksheets and tables.
Worksheet as Table
Anyone who works with data for a significant period of time will make the realization that a table is a table is a table. You can represent a table as a comma separated value (CSV, which doesn’t actually need to be separated exclusively by commas) document, where the first line contains the delimited field names, and the second and subsequent lines contain the delimited row values.
A relational database does almost exactly the same thing, using field name as a proxy for field position (which in turn, for a given row, is also the field position for the value in that row). Most databases also provide some schematic metadata (a schema definition or DDL) for managing things like data type representation and storage capacity for a given field. The table also has a name.
An Excel worksheet as CSV works the same way as well — the first row contains field names, and subsequent rows contain the values associated with the field position. The primary difference is that the table name is now replaced with the worksheet name, and the database in turn corresponds to the workbook.
Where things get interesting is that this same correspondance happens in RDF. RDF is a normalized data language — this means that every hierarchy has been decomposed into flat tables. In the simplest cases, this also means that you can do a one to one correspondance between a relational database, a spreadsheet, and a set of triples, with the only caveat on the latter being that you have to be careful to generate global identification keys (URIs or IRIs) that can be derived from the tabular information.
This is the approach that we’ll take in the current article.
Describing an NPC
When I was younger, I was an avid role play gamer (and dungeon master, naturally), drawn to such games as Dungeons and Dragons, Champions, GURPs and so forth not only because of the opportunity to exercise my imagination, but also because there were so many cool tables within the books, and as a data geeky kid, that was the height of nerd-dom. As a consequence, it’s probably no surprise that RPGs have become my “go-to” examples for showcasing data modeling and structures, primarily because they are complex enough to be interesting but not so complex as to be confusing, and because I have not met a programmer yet who doesn’t have at least one favorite RPG character sheet floating around somewhere in their back closet.
So, to illustrate the idea (and to start with some data) I created an Excel Workbook called Game.xlsx, and laid out an arbitrary set of sample characters. To make it easier to format, I’ve broken this into multiple tables below:
It’s worth noting that this isn’t comprehensive. Rather it’s just enough to showcase various patterns in making triples.
The breakdown in the tables above is not quite accidental. The Name in all cases is assumed out of the gate to be unique — in other words, it can serve as the basis for a key. Note that for externally gathered data sets, this is not always a safe assumption — multiple people may have the same name, so other keys (either individual or combined) may need to be processed to create a unique identifier.
The techniques for key (and ultimately URI) generation vary, but in the examples given here, npc ids are best upon the name attribute, typically by compressing white space and other punctuation out of the name. Thus, “Mara GlynisDottir” generates the uri key npc:_MaraGlynisDottir. It's worth noting here that as what's being generated is not a direct one-to-one representation of a database but rather more of a conceptual representation, you can specify the conceptual namespace rather than trying to identify a given system database. However, if you WERE looking at creating a URI based for the npc based upon a system identifier (here, system 125, which might be a spreadsheet), it might look like:
npc:_System_125_MaraGlynisdottir
The important point to remember here is to insure that the key can be made globally universally. If System 125 and System 130 each have a Mara Glynisdottir, then they should be treated as distinct entities until proven to be most likely the same (out of scope of this article).
Associations occur frequently in spreadsheets, where they are typically given as names rather than keys. These usually fall into what are often thought of as controlled vocabularies, or category terms. For instance, gender has four potential category terms (here, “Male”, “Female”, “Androgynous” and “Not Applicable”), each associated with a Gender Vocabulary. This in turn suggests that, while you can create a table just for Gender, another for Species and so forth, the fact that these are all terms that mostly have the same set of relevant characteristics suggests that it would be better to create two tables — one for vocabularies, a second for terms.
In the spreadsheet example, this was done by creating two more worksheets with Terms and Vocabs as the respective tab names.
The second column of the Term worksheet is a vocabulary name from the Vocab worksheet. So long as you can insure that keys made from each of these tables are consistent with each other and with the NPC table, you should be able to turn these into primary key/foreign key links. The full example here defines eighty terms across seven vocabularies, though in a real application there could be dozens to hundreds of these tables, with potentially tens of thousand of classification categories. Such categories or associations can also be thought of as facets, as entities (such as NPCs) can be thought of as being combinations of facets and corresponding attributes (such as names or scores).
The attributes, on the other hands, are generally atomic values — strings, numbers, dates, what a scientist would call scalars. There were at least two ways that attributes could have been defined. The first would have been as specific properties (such as strength or intelligence). These might be modeled as:
npc:_MaraGlynisDottir a npc: ; npc:hasStrength 9 ; npc:hasIntelligence 18 ; ...
where there is a specific property (or predicate) for each of the attributes, or it could have been modeled as:
npc:_MaraGlynisDottir a npc: ;
npc:hasAttribute [
attribute:value 9;
attribute:type term:_AttributeType_Strength;
],
[
attribute:value 18;
attribute:type term:_AttributeType_Intelligence;
];
...
where there is only one predicate (npc:hasAttribute) and then a value and associated type.
More because of application utility, the first form was preferred in the case of attributes (you are typically comparing specific attribute values, for instance). cibstryctHowever, the downside to this is that if attributes are not specific scalars but more complex structures, you would have to go with the alternative approach. From a modeling standpoint, you can create an association between a specific attribute predicate and it’s associated type:
construct {?npc npc:hasStrength ?value} where {
npc:hasStrength a owl:DataProperty.
?npc a npc: .
?npc npc:hasAttribute ?attribute.
?attribute attribute:type term:_AttributeType_Strength.
?attribute attribute:value ?value.
}
The predicate npc:hasStrength isn't quite a subclass of npc:hasAttribute, as the former is a data property while the latter is an object property, but there is a relationship between them.
The more expansive form is used with coins. The game has five different kinds of currencies, based upon the penny (essentially the wage that a manual laborer would earn for a day of labor). A coin has a generalized value (denoted by the default property value), with the smallest currency (a farthing) being a twelfth of a penny (about an hour’s worth of labor), and every unit above that being twelve times larger. Thus, the total wealth of an individual could be determined by multiplying the number of coins of each type by the default value for that type. This is a computation that can be accomplished using SPARQL, and as it is one metric for determining success in the game, being able to compute in situ made sense. 25 Solad 14 Lunad 3 Crescent would then be calculated at 5 * 123 + 14 * 122+ 3 * 12, or 10,693 pennies. In the game world, most peasants generally did not see Solads very often, as might be expected.
Each coin collection is then represented by a purse (so, one purse for solads, one for lunads and so forth). A purse would be represented as:
[
a purse: ;
purse:type term:_Currency_Solad ;
purse:count "22"^^xsd:integer
]
and the total of all purses for each npc could be calculated as follows (SPARQL):
select ?npc (sum(?purseSum) as ?totalPennies) where {
?npc a npc: .
?npc npc:hasPurse ?purse.
?purse purse:type ?purseType.
?purseType term:defaultValue ?multiplier.
?purse purse:count ?purseCount.
bind (?purseCount * ?multiplier as ?purseSum)
} group by ?npc
The final table that would need to be constructed within the Excel document would be the “Namespaces” table. This table matches the prefix with the namespace and a description:
with the intent that both are focused on Turtle/Sparql. In the first case, It makes it possible to insure that namespaces are identified locally, so no additional configuration file is needed. Additionally, these are used by both the turtle generation files and for adding SHACL information about namespaces to the dataset.
Writing the Ingestor
Most of this focuses on spending the time to build out the Excel document. Once that work is done, though, the actual transformations can be handled with two of my favorite tools — template literals, and the node.js based XLSX.js module. The latter handy tool comes in three versions — a reasonably powerful community version (which is free and open source) and then two more powerful enterprise versions that allow for better formatting of Excel documents. The sample code I generate here makes use of the community version.
I have a version of a simple ingestor on git that includes the XSLX file.
> git clone https://github.com/kurtcagle/ingestor.git
> cd ingestor
> npm init
> npm install xlsx
This ingestor is NOT general purpose — it is only intended to show how the process works. I am working on a more generalized ingestor that will be run as a service, and will include information about that in the GitHub notes as I move forward.
The file index.js contains the relevant Javascript code:
// index for ingestor
if(typeof require !== 'undefined') XLSX = require('xlsx');
function compactToken(expr){
expr = expr.replace(/[^\w\s]|_/g, "").replace(/\s+/g, " ");
var tokens = expr.trim().split(/\s+/);
return tokens.map((token)=>token.substr(0,1).toUpperCase()+token.substr(1)).join('');
}
var workbook = XLSX.readFile('Game.xlsx');
var nsWorksheet = workbook.Sheets["_Namespaces_"];
var nsArr = XLSX.utils.sheet_to_json(nsWorksheet);
var nsTemplate = (row)=>`@prefix ${row.prefix}: <${row.namespace.trim()}>.`;
var turtleDecl = nsArr.map((row)=>nsTemplate(row)).join('\n')+'\n';
console.log(turtleDecl);
var shaclDeclTemplate = (nsArr)=>`
shape:
sh:declare
${nsArr.map((row)=>`
[
sh:prefix "${row.prefix}"^^xsd:string ;
sh:namespace <${row.namespace.trim()}> ;
sh:description """${row.description.trim()}"""^^xsd:string ;
]`).join(',\n')} .
`;
console.log(shaclDeclTemplate(nsArr))
var vocabWorksheet = workbook.Sheets["Vocab"];
var vocabArr = XLSX.utils.sheet_to_json(vocabWorksheet);
var vocabTemplate = (row)=>`
vocab:_${compactToken(row["Pref Label"])}
a vocab: ;
vocab:prefLabel "${row["Pref Label"]}"^^xsd:string ;
rdfs:label "${row["Pref Label"]}"^^xsd:string ;
vocab:description "${row["Description"]}"^^xsd:string ;
.`;
var vocabs = vocabArr.map((row)=>vocabTemplate(row)).join("\n");
console.log(vocabs);
var termWorksheet = workbook.Sheets["Term"];
var termArr = XLSX.utils.sheet_to_json(termWorksheet);
var termTemplate = (row)=>`
term:_${compactToken(row["Vocab"])}_${compactToken(row["Pref Label"])}
a term: ;
term:prefLabel "${row["Pref Label"]}"^^xsd:string ;
rdfs:label "${row["Pref Label"]}"^^xsd:string ;
term:hasVocab vocab:_${compactToken(row["Vocab"])} ;
${row["Symbol"]?`term:symbol "${row["Symbol"]}"^^xsd:string ;`:''}
${row["Default Value"]?`term:defaultValue "${row["Default Value"]}"^^xsd:string ;`:''}
term:description """${row["Description"]}"""^^xsd:string ;
.`;
var terms = termArr.map((row)=>termTemplate(row)).join("\n");
console.log(terms);
var npcWorksheet = workbook.Sheets["NPC"];
var npcArr = XLSX.utils.sheet_to_json(npcWorksheet);
var npcAttributes = [
"Strength","Endurance","Agility",
"Dexterity","Presence","Attractiveness",
"Sanity","Intelligence","Memory",
"Wisdom","Magic","Luck","Creativity"];
var currencies = ["Farthing","Penny","Crescent","Lunad","Solad"]
var npcTemplate = (row,npcAttributes)=>`
npc:_${compactToken(row.Name)}
a npc: ;
term:prefLabel "${row.Name}"^^xsd:string ;
rdfs:label "${row.Name}"^^xsd:string ;
npc:hasGender term:_Gender_${compactToken(row.Gender)} ;
npc:hasSpecies term:_Species_${compactToken(row.Species)} ;
npc:hasVocation term:_Vocation_${compactToken(row.Vocation)} ;
npc:hasApparentAge term:_ApparentAge_${compactToken(row["Apparent Age"])} ;
npc:hasAlignment term:_Alignment_${compactToken(row.Alignment)} ;
${npcAttributes.map((attribute)=>`
npc:has${compactToken(attribute)} "${row[attribute]}"^^xsd:integer ;`).join('')};
npc:hasPurse ${currencies.map((currency)=>`
[
a purse: ;
purse:currencyType term:_Currency_${compactToken(currency)} ;
purse:count "${row[currency]}"^^xsd:integer
]`)};
npc:description """${row["Description"]}"""^^xsd:string ;
.`;
var npcs = npcArr.map((row)=>npcTemplate(row,npcAttributes)).join("\n");
console.log(npcs);
The function compactToken() will take a string and remove punctuation and generate a compact (no space) camelCase version of the string. It's used primarily to help generate the URIs.
The xlsx object includes support for loading in an Excel document and converting it internally into a series of Javascript objects. The base object is a representation of the full workbook, while the array workbook.Sheets[] can be used to retrieve the named sheets.
What follows after this section is effectively repeating versions of the same code. The first section retrieves the “Namespaces” worksheet from the array, and uses a utility function to convert this into an array of objects, using the names in the first row as the property names for each object (i.e.,
var nsArr = [
{
prefix:"npc",
namespace:"https://meta.semantical.com/npc/",
description:"Game namespace for non-player characters."
}, ...
]
A template literal is then created for handling each row:
var nsTemplate = (nsNode)=>`@prefix ${nsNode.prefix}: <${nsNode.namespace.trim()}> .`;
Once created, the application iterates over the array and maps each node (row) to a Turtle prefix declaration:
@prefix term: <http://meta.semantical.com/term/>.
@prefix vocab: <http://meta.semantical.com/vocab/>.
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>.
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.
@prefix owl: <http://www.w3.org/2001/XMLSchema#>.
@prefix property: <http://meta.semantical.com/property/>.
@prefix sh: <http://www.w3.org/ns/shacl#>.
@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
@prefix shape: <http://meta.semantical.com/shape/>.
@prefix npc: <http://meta.semantical.com/npc/>.
@prefix attribute: <http://meta.semantical.com/attribute/>.
@prefix purse: <http://meta.semantical.com/purse/>.
The second section does something similar, except that rather than generating the turtle prefix declarations, it creates SHACL declarations for the same resources:
shape:
sh:declare
[
sh:prefix "term"^^xsd:string ;
sh:namespace <https://meta.semantical.com/term/> ;
sh:description """Namespace for concept terms within controlled vocabularies,"""^^xsd:string ;
],
[
sh:prefix "vocab"^^xsd:string ;
sh:namespace <https://meta.semantical.com/vocab/> ;
sh:description """Namespace for controlled vocabulary,"""^^xsd:string ;
],
[
sh:prefix "rdf"^^xsd:string ;
sh:namespace <https://www.w3.org/1999/02/22-rdf-syntax-ns#> ;
sh:description """RDF namespace"""^^xsd:string ;
],
[
sh:prefix "rdfs"^^xsd:string ;
sh:namespace <https://www.w3.org/2000/01/rdf-schema#> ;
sh:description """RDF Schema namespace"""^^xsd:string ;
],
[
sh:prefix "owl"^^xsd:string ;
sh:namespace <https://www.w3.org/2001/XMLSchema#> ;
sh:description """OWL namespace (if needed)"""^^xsd:string ;
],
[
sh:prefix "property"^^xsd:string ;
sh:namespace <https://meta.semantical.com/property/> ;
sh:description """Namespace for property declarations - may not be needed."""^^xsd:string ;
],
[
sh:prefix "sh"^^xsd:string ;
sh:namespace <https://www.w3.org/ns/shacl#> ;
sh:description """SHACL namespace"""^^xsd:string ;
],
[
sh:prefix "xsd"^^xsd:string ;
sh:namespace <https://www.w3.org/2001/XMLSchema#> ;
sh:description """XML Schema Defintion namespace"""^^xsd:string ;
],
[
sh:prefix "shape"^^xsd:string ;
sh:namespace <https://meta.semantical.com/shape/> ;
sh:description """Sample namespace for shapes in the semantical namespace"""^^xsd:string ;
],
[
sh:prefix "npc"^^xsd:string ;
sh:namespace <https://meta.semantical.com/npc/> ;
sh:description """Game namespace for non-player characters."""^^xsd:string ;
],
[
sh:prefix "attribute"^^xsd:string ;
sh:namespace <https://meta.semantical.com/attribute/> ;
sh:description """Namespace for attribute types, the characteristics that identify a character's base abilities."""^^xsd:string ;
],
[
sh:prefix "purse"^^xsd:string ;
sh:namespace <https://meta.semantical.com/purse/> ;
sh:description """Namespace identifying purse objects."""^^xsd:string ;
] .
The section after that generates the vocabulary declarations, followed by the term definitions:
# Vocabularies vocab:_Gender a vocab: ; vocab:prefLabel "Gender"^^xsd:string ; rdfs:label "Gender"^^xsd:string ; vocab:description "An indication of the sexual identity of an individual."^^xsd:string ; . vocab:_Species a vocab: ; vocab:prefLabel "Species"^^xsd:string ; rdfs:label "Species"^^xsd:string ; vocab:description "An indication of the origin and form of a given individual"^^xsd:string ; . # Terms term:_Species_Human a term: ; term:prefLabel "Human"^^xsd:string ; rdfs:label "Human"^^xsd:string ; term:hasVocab vocab:_Species ; term:description """The most numerous sentient race in the game world. Males are 1.9 meters tall on average, females are 1.6 m. Bipedal, with rounded ears and eyes, relatively bare monochromatic skin in shades of pink to dark brown, typically living to 78 years (Old Terran)."""^^xsd:string ; . term:_Species_Elf a term: ; term:prefLabel "Elf"^^xsd:string ; rdfs:label "Elf"^^xsd:string ; term:hasVocab vocab:_Species ; term:description """A humanoid (and probably once human) race that has been adapted for life in a high magic environment. They are typically thinner than humans and slightly smaller (1.85m and 1.55m respectively), with pointed ears, hair that can range through human shades but can also be blues, pinks, purples, or similar colors, and skin that ranges from pale pink to dark blue or slate gray. They are much longer lived, with the average being 350 years and the oldest reputedly more than a millennium old."""^^xsd:string ; . ...
The final section generates the NPC, which is easily the most complex object. The Javascript deals with both the mapping to the associations (connecting to the URIs first defined by the previous sections) then creates the associations, then finally handles the currencies:
var npcWorksheet = workbook.Sheets["NPC"];
var npcArr = XLSX.utils.sheet_to_json(npcWorksheet);
var npcAttributes = [
"Strength","Endurance","Agility",
"Dexterity","Presence","Attractiveness",
"Sanity","Intelligence","Memory",
"Wisdom","Magic","Luck","Creativity"];
var currencies = ["Farthing","Penny","Crescent","Lunad","Solad"]
var npcTemplate = (row,npcAttributes)=>`
npc:_${compactToken(row.Name)}
a npc: ;
term:prefLabel "${row.Name}"^^xsd:string ;
rdfs:label "${row.Name}"^^xsd:string ;
npc:hasGender term:_Gender_${compactToken(row.Gender)} ;
npc:hasSpecies term:_Species_${compactToken(row.Species)} ;
npc:hasVocation term:_Vocation_${compactToken(row.Vocation)} ;
npc:hasApparentAge term:_ApparentAge_${compactToken(row["Apparent Age"])} ;
npc:hasAlignment term:_Alignment_${compactToken(row.Alignment)} ;
${npcAttributes.map((attribute)=>` npc:has${compactToken(attribute)} "${row[attribute]}"^^xsd:integer ;`).join('')};
npc:hasPurse ${currencies.map((currency)=>`
[
a purse: ;
purse:currencyType term:_Currency_${compactToken(currency)} ;
purse:count "${row[currency]}"^^xsd:integer
]`)};
npc:description """${row["Description"]}"""^^xsd:string ;
.`;
var npcs = npcArr.map((row)=>npcTemplate(row,npcAttributes)).join("\n");
console.log(npcs);
Note that with fragment identifiers, you can also iterate through predefined lists (such as the attribute list) to generate not just subjects and objects but also even predicates such as npc:hasStrength. This creates the output:
npc:_MaraGlynisDottir
a npc: ;
term:prefLabel "Mara GlynisDottir"^^xsd:string ;
rdfs:label "Mara GlynisDottir"^^xsd:string ;
npc:hasGender term:_Gender_Female ;
npc:hasSpecies term:_Species_HalfElf ;
npc:hasVocation term:_Vocation_Intelligencer ;
npc:hasApparentAge term:_ApparentAge_Adult ;
npc:hasAlignment term:_Alignment_NeutralGood ;
npc:hasStrength "9"^^xsd:integer ;
npc:hasEndurance "10"^^xsd:integer ;
npc:hasAgility "9"^^xsd:integer ;
npc:hasDexterity "14"^^xsd:integer ;
npc:hasPresence "15"^^xsd:integer ;
npc:hasAttractiveness "17"^^xsd:integer ;
npc:hasSanity "15"^^xsd:integer ;
npc:hasIntelligence "18"^^xsd:integer ;
npc:hasMemory "17"^^xsd:integer ;
npc:hasWisdom "14"^^xsd:integer ;
npc:hasMagic "15"^^xsd:integer ;
npc:hasLuck "11"^^xsd:integer ;
npc:hasCreativity "14"^^xsd:integer ;;
npc:hasPurse
[
a purse: ;
purse:currencyType term:_Currency_Farthing ;
purse:count "35"^^xsd:integer
],
[
a purse: ;
purse:currencyType term:_Currency_Penny ;
purse:count "15"^^xsd:integer
],
[
a purse: ;
purse:currencyType term:_Currency_Crescent ;
purse:count "12"^^xsd:integer
],
[
a purse: ;
purse:currencyType term:_Currency_Lunad ;
purse:count "135"^^xsd:integer
],
[
a purse: ;
purse:currencyType term:_Currency_Solad ;
purse:count "22"^^xsd:integer
];
npc:description """An intelligent young witch"""^^xsd:string ;
.
In this example, this outputs to the console log. To run it in a console, type in:
> node index.js
or, if you want to retain the output to look at it in a text processor, run it as:
> node index.js > output.ttl
Summary
This is intended to illustrate basic concepts, such as the use of template literals and the importance of designing your Excel with a specific format/target in mind. I’m working on a more generalized version of this which can be run in parameterized form or as a service, and that will use fairly self-evident heuristics to both manage basic use cases (doing a straight map with no modifications) and to override certain functionality with helper modules. Additionally, I intend to expand the range to most RDF formats over time, though Turtle will have the highest priority.
Kurt Cagle is a writer, information architect and general ne’er do well. He lives in Issaquah, Washington, and watches the rain come down accompanied by his cat. #TheCagleReport
#RDF #Turtle #Excel #Javascript #NodeJS #TemplateLiterals #ETL
?
Consultant specializing in Election Integrity and Cloud AI frameworks and Cryptology technologies. Maryland coordinator for implementing the FAIRtax.
6 年Super stuff Kurt. More people need to learn how to use these techniques and tools.