├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── examples └── synopsis.pdf ├── img ├── chooseWorkspace.png ├── copyToken.png ├── exampleSchema.png ├── getApiToken.png └── showApiSchema.png ├── package.json ├── src ├── TanaAPIClient.ts ├── examples │ ├── books.ts │ ├── smokeTest.ts │ └── upload.ts ├── types │ ├── constants.ts │ └── types.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/settings.json 2 | .DS_Store 3 | node_modules 4 | dist 5 | .env 6 | datasets 7 | yarn-error.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "printWidth": 120, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "arrowParens": "always" 8 | } 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | ### Code of Conduct 4 | 5 | #### Examples of behavior that contributes to creating a positive environment include: 6 | 7 | - Using welcoming and inclusive language 8 | - Being respectful of differing viewpoints and experiences 9 | - Gracefully accepting constructive criticism 10 | - Showing empathy towards other team members 11 | 12 | #### Examples of unacceptable behavior by participants include: 13 | 14 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 15 | - Trolling, insulting/derogatory comments, and personal or political attacks 16 | - Public or private harassment 17 | - Other conduct which could reasonably be considered inappropriate in a professional setting 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Cerebral 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Twitter Follow 2 | 3 | 4 | 5 | This project has a basic client for Tana's Input API and example scripts. 6 | 7 | # Tana Input API 8 | 9 | The Tana Input API is an API that allows you to add data (including tags) to Tana Graphs. 10 | (APIs for reading from Tana Graphs are currently not available but on our roadmap) 11 | 12 | With this API, you can: 13 | - Send nodes, including children, to Tana 14 | - Create or add existing tags and fields to the nodes being sent 15 | 16 | ## Restrictions 17 | Please note that the API currently has the following restrictions: 18 | 19 | - One call per second per token 20 | - Max 100 nodes created per call 21 | - Max 5000 chars in one request 22 | 23 | Let us know in our Slack-community if you are hitting these limits, as we have set them at a rather arbitrary number for now. 24 | 25 | ## Before you start 26 | You need an API token to work with the Tana Input API. 27 | Tokens are assigned per workspace and can be generated in the Tana Client: 28 | 29 | 1. In the upper right corner of your Tana client, choose API Tokens
30 | 31 | 32 | 33 | 34 | 2. Select which workspace you want to create a token for, and click create token:
35 | 36 | 37 | 38 | 3. Click Copy to copy the token you just created
39 | 40 | 41 | 42 | # 👨‍💻 How to use this client 43 | 44 | The client uses node-fetch, but native fetch can be used from Node 17.5 with `--experimental-fetch` 45 | 46 | Install the deps with `yarn install`, then see below for the examples 47 | 48 | ## Using existing tags 49 | 50 | This can be found, including generated code, on the Supertag by running the command “Show API schema” on the title of the tag in the tag config or in the Schema section: 51 | 52 |
53 | 54 | 55 | A typical schema looks like this:
56 | ``` 57 | type Node = { 58 | name: string; 59 | description?: string; 60 | supertags: [{ 61 | /* meeting */ 62 | id: 'MaaJRCypzJ' 63 | }]; 64 | children: [ 65 | { 66 | /* Date */ 67 | type: 'field'; 68 | attributeId: 'iKQAQN38Vx'; 69 | children: [{ 70 | dataType: 'date'; 71 | name: string; 72 | }]; 73 | }, 74 | ]; 75 | }; 76 | ``` 77 | 78 | And comes with a payload example: 79 | ``` 80 | { 81 | "nodes": [ 82 | { 83 | "name": "New node", 84 | "supertags": [ 85 | { 86 | "id": "MaaJRCypzJ" 87 | } 88 | ], 89 | "children": [ 90 | { 91 | "type": "field", 92 | "attributeId": "iKQAQN38Vx", 93 | "children": [ 94 | { 95 | "dataType": "date", 96 | "name": "2023-05-10T08:25:59.059Z" 97 | } 98 | ] 99 | }, 100 | { 101 | "name": "Child node" 102 | } 103 | ] 104 | } 105 | ] 106 | } 107 | ``` 108 | 109 | 110 | ## Creating fields & tags 111 | To create a new field definition you create a node with the supertag set to `SYS_T02`. You'll likely want to target the Schema-node, which would be `SCHEMA`, but you are free to place tags elsewhere as well. This might be useful when creating templates for example 112 | 113 | ``` 114 | { 115 | targetNodeId: 'SCHEMA', 116 | nodes: [ 117 | { 118 | name: 'Author', 119 | description: 'Who wrote the book?', 120 | supertags: [{id:'SYS_T02'}] 121 | }, 122 | { 123 | name: 'My rating', 124 | description: 'How was it?', 125 | supertags: [{id:'SYS_T02'}] 126 | } 127 | ] 128 | } 129 | ``` 130 | 131 | To create a tag, set the supertag to `SYS_T01`: 132 | 133 | ``` 134 | { 135 | targetNodeId: 'SCHEMA', 136 | nodes: [ 137 | { 138 | name: 'Book', 139 | description: 'A supertag for my books', 140 | supertags: [{id:'SYS_T01'}], 141 | children: [] 142 | } 143 | ] 144 | } 145 | ``` 146 | 147 | ## Creating nodes 148 | Nodes will be placed in Library by default, unless targetNodeId is specified 149 | 150 | ``` 151 | { 152 | nodes: [ 153 | { 154 | name: 'The Hobbit', 155 | description: 'A book by J.R.R. Tolkien', 156 | supertags: [{id:'MyTagId'}], 157 | children: [] 158 | } 159 | ] 160 | } 161 | ``` 162 | 163 | ## Targeting Tana standard nodes 164 | You can target the Tana standard nodes *Schema* (where you typically will put the Supertags and Fields you create) and the *Inbox*. 165 | 166 | Use these methods in the TanaAPIClient to get the ID for these nodes: 167 | tanaAPIHelper.schemaNodeId() 168 | tanaAPIHelper.inboxNodeId() 169 | 170 | We currently don't support targeting the Today-node 171 | 172 | ## Example:Books 173 | 174 | This example shows how we can create new fields, and then a new tag using those fields. We then create a few books using the tag, and add some extra content to the books afterwards. 175 | 176 | `TANA_TOKEN=token yarn run example:books` 177 | 178 | ## Example:Upload 179 | 180 | This example is a basic uploader for `m4a` audio-files from some specified directory to the 181 | Tana inbox. It creates a file `tana-upload-state.json` to keep track of what's already uploaded. 182 | 183 | `TANA_TOKEN=token yarn run example:upload ~/Recordings/` 184 | 185 | # Current limitations 186 | 187 | Rate limiting: 188 | - One call per second per token 189 | - Max 100 nodes created per call 190 | - Max 5000 chars in one request 191 | 192 | Other limitations: 193 | - You cannot target a relative Today-node 194 | - To add tags/fields, you’ll have to know the IDs of the Supertag. To get it, run the command “Show API schema” on a tag 195 | - The payload is currently capped at 5K. Let us know if this is too low (and why) 196 | - There’s a new endpoint for the updated API 197 | - You cannot send a checkbox as a child to a normal node 198 | - We don’t support the initialization function 199 | - We don’t support child templates 200 | 201 | # ✍️ Contributing 202 | 203 | Feedback, PRs and suggestions for improvements will be highly appreciated. 204 | Make sure you read our [Code of Conduct](CODE_OF_CONDUCT.md) 205 | -------------------------------------------------------------------------------- /examples/synopsis.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanainc/tana-input-api-samples/1cc504ef5b2764698fc0cd1cac88132e5e60c4ba/examples/synopsis.pdf -------------------------------------------------------------------------------- /img/chooseWorkspace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanainc/tana-input-api-samples/1cc504ef5b2764698fc0cd1cac88132e5e60c4ba/img/chooseWorkspace.png -------------------------------------------------------------------------------- /img/copyToken.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanainc/tana-input-api-samples/1cc504ef5b2764698fc0cd1cac88132e5e60c4ba/img/copyToken.png -------------------------------------------------------------------------------- /img/exampleSchema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanainc/tana-input-api-samples/1cc504ef5b2764698fc0cd1cac88132e5e60c4ba/img/exampleSchema.png -------------------------------------------------------------------------------- /img/getApiToken.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanainc/tana-input-api-samples/1cc504ef5b2764698fc0cd1cac88132e5e60c4ba/img/getApiToken.png -------------------------------------------------------------------------------- /img/showApiSchema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tanainc/tana-input-api-samples/1cc504ef5b2764698fc0cd1cac88132e5e60c4ba/img/showApiSchema.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "license": "MIT", 3 | "type": "module", 4 | "dependencies": { 5 | "node-fetch": "^3.2.10", 6 | "readline": "^1.3.0", 7 | "zod": "^3.21.4" 8 | }, 9 | "devDependencies": { 10 | "@types/node": "^18.15.11", 11 | "typescript": "^5.0.3" 12 | }, 13 | "scripts": { 14 | "prepare": "rm -rf dist/ && ./node_modules/.bin/tsc --project tsconfig.json", 15 | "build": "yarn prepare", 16 | "example:books": "yarn build && node --experimental-modules --es-module-specifier-resolution=node dist/examples/books.js", 17 | "example:upload": "yarn build && node --experimental-modules --es-module-specifier-resolution=node dist/examples/upload.js", 18 | "example:smokeTest_internal": "yarn build && node --experimental-modules --es-module-specifier-resolution=node dist/examples/smokeTest.js" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/TanaAPIClient.ts: -------------------------------------------------------------------------------- 1 | import { attrDefTemplateId, coreTemplateId } from './types/constants'; 2 | import { APIField, APINode, APIPlainNode, TanaNode } from './types/types'; 3 | import fetch from 'node-fetch'; 4 | import { readFileSync } from 'fs'; 5 | 6 | function readfile(filename: string) { 7 | // read filename from disk, return array buffer 8 | const file = readFileSync(filename, { encoding: 'base64' }); 9 | 10 | return file; 11 | } 12 | export class TanaAPIHelper { 13 | private endpoint = 'https://europe-west1-tagr-prod.cloudfunctions.net/addToNodeV2'; 14 | 15 | private get schemaNodeId() { 16 | return `SCHEMA`; 17 | } 18 | 19 | constructor(public token: string, public endpointUrl?: string) { 20 | this.token = token; 21 | 22 | if (endpointUrl) { 23 | this.endpoint = endpointUrl; 24 | } 25 | } 26 | 27 | async createFieldDefinitions(fields: APIPlainNode[]) { 28 | const payload = { 29 | targetNodeId: this.schemaNodeId, 30 | nodes: fields.map((field) => ({ 31 | name: field.name, 32 | description: field.description, 33 | supertags: [{ id: attrDefTemplateId }], 34 | })), 35 | }; 36 | 37 | const createdFields = await this.makeRequest(payload); 38 | 39 | return createdFields.map((field: any) => ({ 40 | name: field.name as string, 41 | description: field.description as string, 42 | id: field.nodeId as string, 43 | })); 44 | } 45 | 46 | async createTagDefinition(node: APIPlainNode) { 47 | if (!node.supertags) { 48 | node.supertags = []; 49 | } 50 | node.supertags.push({ id: coreTemplateId }); 51 | const payload = { 52 | targetNodeId: this.schemaNodeId, 53 | nodes: [node], 54 | }; 55 | 56 | const createdTag = await this.makeRequest(payload); 57 | return createdTag[0].nodeId; 58 | } 59 | 60 | async createNode(node: APINode, targetNodeId?: string) { 61 | const payload = { 62 | targetNodeId: targetNodeId, 63 | nodes: [node], 64 | }; 65 | 66 | const createdNode = await this.makeRequest(payload); 67 | return createdNode[0]; 68 | } 69 | 70 | async setNodeName(newName: string, targetNodeId?: string) { 71 | const payload = { 72 | targetNodeId: targetNodeId, 73 | setName: newName, 74 | }; 75 | 76 | const createdNode = await this.makeRequest(payload); 77 | return createdNode; 78 | } 79 | 80 | async addField(field: APIField, targetNodeId?: string) { 81 | const payload = { 82 | targetNodeId: targetNodeId, 83 | nodes: [field], 84 | }; 85 | 86 | const createdNode = await this.makeRequest(payload); 87 | return createdNode; 88 | } 89 | 90 | private async makeRequest( 91 | payload: { targetNodeId?: string } & ({ nodes: (APINode | APIField)[] } | { setName: string }), 92 | ): Promise { 93 | const response = await fetch(this.endpoint, { 94 | method: 'POST', 95 | headers: { 96 | Authorization: 'Bearer ' + this.token, 97 | 'Content-Type': 'application/json', 98 | }, 99 | body: JSON.stringify(payload), 100 | }); 101 | 102 | if (response.status === 200 || response.status === 201) { 103 | const json = await response.json(); 104 | if ('setName' in payload) { 105 | return [json] as any as TanaNode[]; 106 | } else { 107 | return (json as any).children as TanaNode[]; 108 | } 109 | } 110 | console.log(await response.text()); 111 | throw new Error(`${response.status} ${response.statusText}`); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/examples/books.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { TanaAPIHelper } from '../TanaAPIClient'; 3 | import { waitForEnter } from '../utils'; 4 | 5 | const token = process.env.TANA_TOKEN || ''; 6 | 7 | if (!token) { 8 | console.log('Please set TANA_TOKEN in your environment'); 9 | process.exit(1); 10 | } 11 | 12 | console.log('----------------------------------------'); 13 | console.log('This script will create a tag called "Book"'); 14 | console.log('It will then create a books with that tag andd some contents to it'); 15 | console.log(''); 16 | console.log('Hit enter to go to the next step'); 17 | console.log('The rate limit is one call per second'); 18 | console.log('----------------------------------------'); 19 | 20 | const run = async () => { 21 | // Create new api client with your token and workspace id 22 | const tanaAPIHelper = new TanaAPIHelper(token); 23 | 24 | await waitForEnter('Hit enter to create a few fields'); 25 | 26 | // Create some fields for our book tag 27 | const fields = await tanaAPIHelper.createFieldDefinitions([ 28 | { 29 | name: 'Author', 30 | description: 'Who wrote the book', 31 | }, 32 | { 33 | name: 'My rating', 34 | description: 'How I rated the book', 35 | }, 36 | { 37 | name: 'Purchase date', 38 | description: 'When I bought the book', 39 | }, 40 | ]); 41 | 42 | console.log(fields); 43 | 44 | await waitForEnter('Fields created, hit enter to create tag'); 45 | 46 | // create the book tag itself, using the fields we just created 47 | const bookTagId = await tanaAPIHelper.createTagDefinition({ 48 | name: 'Book', 49 | description: 'A book', 50 | children: fields.map((field) => ({ 51 | attributeId: field.id, 52 | type: 'field', 53 | children: [{ name: '' }], 54 | })), 55 | }); 56 | 57 | if (!bookTagId) { 58 | console.log('bookTagId is undefined, something went wrong creating the tag'); 59 | return; 60 | } 61 | 62 | console.log('bookTagId', bookTagId); 63 | await waitForEnter('Tag created, hit enter to create an author'); 64 | 65 | // get references to the fields we created earlier 66 | const authorFieldId = fields.find((field) => field.name === 'Author')?.id; 67 | const myRatingFieldId = fields.find((field) => field.name === 'My rating')?.id; 68 | const purchaseDateFieldId = fields.find((field) => field.name === 'Purchase date')?.id; 69 | 70 | const williamGibson = await tanaAPIHelper.createNode({ 71 | name: 'William Gibson', 72 | description: 'A writer of science fiction', 73 | }); 74 | 75 | console.log(williamGibson); 76 | await waitForEnter('Author created, press enter to create the book'); 77 | 78 | const neuromancer = await tanaAPIHelper.createNode({ 79 | name: 'Neuromancer', 80 | description: 'Got the chiba city blues', 81 | children: [ 82 | { 83 | attributeId: authorFieldId!, 84 | type: 'field', 85 | children: [ 86 | { 87 | id: williamGibson.nodeId!, 88 | dataType: 'reference', 89 | }, 90 | ], 91 | }, 92 | { 93 | attributeId: myRatingFieldId!, 94 | type: 'field', 95 | children: [{ name: '5' }], 96 | }, 97 | { 98 | attributeId: purchaseDateFieldId!, 99 | type: 'field', 100 | children: [{ name: '1984-09-21', dataType: 'date' }], 101 | }, 102 | ], 103 | supertags: [{ id: bookTagId }], 104 | }); 105 | 106 | console.log(neuromancer); 107 | 108 | await waitForEnter('First note created, hit enter to add some notes for the book'); 109 | 110 | // Add my favourite quote from the book, with Neuromancer as target node 111 | const neuromancerQuote = await tanaAPIHelper.createNode( 112 | { 113 | name: 'Favourite quote', 114 | description: 'My favourite quote from the book', 115 | children: [ 116 | { 117 | name: 'Quote', 118 | description: 'The sky above the port was the color of television, tuned to a dead channel.', 119 | }, 120 | ], 121 | }, 122 | neuromancer.nodeId, 123 | ); 124 | 125 | console.log(neuromancerQuote); 126 | 127 | await waitForEnter('Notes added, hit enter to upload a synopsis of the book (pdf)'); 128 | 129 | const filename = 'examples/synopsis.pdf'; 130 | const synposisFileContents = readFileSync(filename, { encoding: 'base64' }); 131 | // Upload a PDF synopsis of the book 132 | const synopsis = await tanaAPIHelper.createNode( 133 | { 134 | filename: 'synopsis.pdf', 135 | dataType: 'file', 136 | contentType: 'application/pdf', 137 | file: synposisFileContents.toString(), 138 | }, 139 | neuromancer.nodeId, 140 | ); 141 | console.log(synopsis); 142 | }; 143 | run().then(() => process.exit(0)); 144 | -------------------------------------------------------------------------------- /src/examples/smokeTest.ts: -------------------------------------------------------------------------------- 1 | import { TanaAPIHelper } from '../TanaAPIClient'; 2 | 3 | const token = process.env.TANA_TOKEN || ''; 4 | 5 | if (!token) { 6 | console.log('Please set TANA_TOKEN in your environment'); 7 | process.exit(1); 8 | } 9 | 10 | // a simple smoke test, internal use only. May serve as an example 11 | const run = async () => { 12 | const tanaAPIHelper = new TanaAPIHelper(token, 'http://127.0.0.1:5001/emulator/europe-west1/addToNodeV2'); 13 | const INLINEREF_NODE_ATTRIBUTE = 'data-inlineref-node'; 14 | 15 | // plain node 16 | const helloWorldId = ( 17 | await expectSuccess(() => 18 | tanaAPIHelper.createNode({ 19 | name: `Hello world`, 20 | }), 21 | ) 22 | ).nodeId; 23 | 24 | // formatting 25 | await expectSuccess(() => 26 | tanaAPIHelper.createNode({ 27 | name: `bold italic striked highlight **bold** __italic__ ~~striked~~ ^^highlight^^`, 28 | }), 29 | ); 30 | 31 | await expectSuccess(() => tanaAPIHelper.setNodeName('foo', helloWorldId!)); 32 | 33 | // valid tag defintion 34 | const myTagId = await expectSuccess(() => 35 | tanaAPIHelper.createTagDefinition({ 36 | name: `My tag`, 37 | }), 38 | ); 39 | 40 | // second valid tag defintion 41 | const myTagId2 = await expectSuccess(() => 42 | tanaAPIHelper.createTagDefinition({ 43 | name: `My tag2`, 44 | }), 45 | ); 46 | 47 | // valid field defintion 48 | const attributeId = ( 49 | await expectSuccess(() => 50 | tanaAPIHelper.createFieldDefinitions([ 51 | { 52 | name: `My field`, 53 | description: 'my field', 54 | }, 55 | ]), 56 | ) 57 | )[0].id; 58 | 59 | // Add field to tag 60 | await expectSuccess(() => tanaAPIHelper.addField({ type: 'field', attributeId }, myTagId!)); 61 | 62 | // create node with multiple tags 63 | await expectSuccess(() => 64 | tanaAPIHelper.createNode({ 65 | name: `some foo`, 66 | supertags: [ 67 | { 68 | id: myTagId!, 69 | }, 70 | { 71 | id: myTagId2!, 72 | }, 73 | ], 74 | }), 75 | ); 76 | 77 | // create a child with multiple tags 78 | await expectSuccess(() => 79 | tanaAPIHelper.createNode({ 80 | name: `Parent`, 81 | children: [ 82 | { 83 | name: 'Child', 84 | supertags: [ 85 | { 86 | id: myTagId!, 87 | }, 88 | { 89 | id: myTagId2!, 90 | }, 91 | ], 92 | }, 93 | ], 94 | }), 95 | ); 96 | 97 | // valid inline ref 98 | await expectSuccess(() => 99 | tanaAPIHelper.createNode({ 100 | name: `hey you`, 101 | }), 102 | ); 103 | 104 | // urls 105 | await expectSuccess(() => 106 | tanaAPIHelper.createNode({ 107 | name: 'url node test', 108 | children: [ 109 | { 110 | dataType: 'url', 111 | name: 'https://tana.inc/', 112 | }, 113 | ], 114 | }), 115 | ); 116 | 117 | // date 118 | await expectSuccess(() => 119 | tanaAPIHelper.createNode({ 120 | name: 'date test', 121 | children: [ 122 | { 123 | dataType: 'date', 124 | name: '2020-01-01', 125 | }, 126 | ], 127 | }), 128 | ); 129 | 130 | // date range 131 | await expectSuccess(() => 132 | tanaAPIHelper.createNode({ 133 | name: 'date range test', 134 | children: [ 135 | { 136 | dataType: 'date', 137 | name: '2020-01-01/2020-01-02', 138 | }, 139 | ], 140 | }), 141 | ); 142 | 143 | // week test 144 | await expectSuccess(() => 145 | tanaAPIHelper.createNode({ 146 | name: 'week test', 147 | children: [ 148 | { 149 | dataType: 'date', 150 | name: '2020-W12/2020-W15', 151 | }, 152 | ], 153 | }), 154 | ); 155 | 156 | // month test 157 | await expectSuccess(() => 158 | tanaAPIHelper.createNode({ 159 | name: 'month test', 160 | children: [ 161 | { 162 | dataType: 'date', 163 | name: '2020-05/2020-08', 164 | }, 165 | ], 166 | }), 167 | ); 168 | 169 | // year test 170 | await expectSuccess(() => 171 | tanaAPIHelper.createNode({ 172 | name: 'year test', 173 | children: [ 174 | { 175 | dataType: 'date', 176 | name: '2020/2021', 177 | }, 178 | ], 179 | }), 180 | ); 181 | 182 | // timestamp range 183 | await expectSuccess(() => 184 | tanaAPIHelper.createNode({ 185 | name: 'date range test', 186 | children: [ 187 | { 188 | dataType: 'date', 189 | name: '2020-01-01 00:00:00/2020-01-02 12:00:00', 190 | }, 191 | ], 192 | }), 193 | ); 194 | 195 | // custom protocol 196 | await expectSuccess(() => 197 | tanaAPIHelper.createNode({ 198 | name: 'url node test', 199 | children: [ 200 | { 201 | dataType: 'url', 202 | name: 'tana://tana.inc/', 203 | }, 204 | ], 205 | }), 206 | ); 207 | 208 | // invalid urls 209 | await expectFailure(() => 210 | tanaAPIHelper.createNode({ 211 | name: 'url node test', 212 | children: [ 213 | { 214 | dataType: 'url', 215 | name: 'https/tana.inc/', 216 | }, 217 | ], 218 | }), 219 | ); 220 | 221 | // fails on newlines 222 | await expectFailure(() => 223 | tanaAPIHelper.createNode({ 224 | name: `Hello \nworld`, 225 | }), 226 | ); 227 | 228 | // ID too short 229 | await expectFailure(() => 230 | tanaAPIHelper.createNode({ 231 | name: 'hello', 232 | children: [ 233 | { 234 | id: 'x', 235 | dataType: 'reference', 236 | }, 237 | ], 238 | }), 239 | ); 240 | 241 | // ID too long 242 | await expectFailure(() => 243 | tanaAPIHelper.createNode({ 244 | name: 'hello', 245 | children: [ 246 | { 247 | id: 'x'.repeat(100), 248 | dataType: 'reference', 249 | }, 250 | ], 251 | }), 252 | ); 253 | 254 | // invalid Id characters 255 | await expectFailure(() => 256 | tanaAPIHelper.createNode({ 257 | name: 'hello', 258 | children: [ 259 | { 260 | id: '_"#$#%&/()', 261 | dataType: 'reference', 262 | }, 263 | ], 264 | }), 265 | ); 266 | 267 | const children: { id: string; dataType: 'reference' }[] = [...Array(101).keys()].map((i) => ({ 268 | id: `xxxxxxxxxxx${i}`, 269 | dataType: 'reference', 270 | })); 271 | 272 | // Too many nodes (max 100) 273 | await expectFailure(() => 274 | tanaAPIHelper.createNode({ 275 | name: 'hello', 276 | children, 277 | }), 278 | ); 279 | 280 | // too long node name (max 80*100) 281 | await expectFailure(() => 282 | tanaAPIHelper.createNode({ 283 | name: 'x'.repeat(81 * 100), 284 | }), 285 | ); 286 | 287 | // bad inline ref 288 | await expectFailure(() => 289 | tanaAPIHelper.createNode({ 290 | name: `hey foo`, 291 | }), 292 | ); 293 | }; 294 | 295 | async function expectFailure(method: () => Promise) { 296 | // wait for 1000ms 297 | await new Promise((resolve) => setTimeout(resolve, 1000)); 298 | 299 | return method().then( 300 | () => { 301 | throw new Error('Expected failure'); 302 | }, 303 | () => { 304 | // Expected 305 | }, 306 | ); 307 | } 308 | 309 | async function expectSuccess(method: () => Promise): Promise { 310 | // wait for 1000ms 311 | await new Promise((resolve) => setTimeout(resolve, 1000)); 312 | 313 | return method().then( 314 | (r) => { 315 | // Expected 316 | return r; 317 | }, 318 | (e) => { 319 | throw new Error('Expected success', { cause: e }); 320 | }, 321 | ); 322 | } 323 | 324 | run().then(() => process.exit(0)); 325 | -------------------------------------------------------------------------------- /src/examples/upload.ts: -------------------------------------------------------------------------------- 1 | import type { APIFileNode } from '../types/types'; 2 | 3 | import { join } from 'node:path'; 4 | import { readFile, writeFile, readdir } from 'node:fs/promises'; 5 | 6 | import { TanaAPIHelper } from '../TanaAPIClient'; 7 | 8 | const token = process.env.TANA_TOKEN || ''; 9 | if (!token) { 10 | console.log('Please set TANA_TOKEN in your environment'); 11 | process.exit(1); 12 | } 13 | 14 | const STATE_FN = 'tana-upload-state.json'; 15 | const folder = process.argv[2] || '.'; 16 | 17 | type State = Record; 18 | 19 | async function processFile(state: State, helper: TanaAPIHelper, path: string, filename: string) { 20 | if (state[filename]?.nodeId) { 21 | return false; 22 | } 23 | 24 | const fileContent = await readFile(path, { encoding: 'base64' }); 25 | const node: APIFileNode = { 26 | filename: `Audio captured from ${filename.replaceAll('/', '_')}`, 27 | dataType: 'file', 28 | contentType: 'audio/mp4', 29 | file: fileContent.toString(), 30 | }; 31 | console.log(`Will upload file: ${filename}`); 32 | const responseObj = await helper.createNode(node, 'INBOX'); 33 | state[filename] = responseObj; 34 | return responseObj; 35 | } 36 | 37 | async function walk(dir: string): Promise { 38 | const files = await readdir(dir, { withFileTypes: true }); 39 | const paths = files.map(async (file) => { 40 | const path = join(dir, file.name); 41 | if (file.isDirectory()) return await walk(path); 42 | return path; 43 | }); 44 | return (await Promise.all(paths)).flat(2); 45 | } 46 | 47 | async function run(folder: string) { 48 | const stateFilePath = join(folder, STATE_FN); 49 | let state: State = {}; 50 | try { 51 | state = JSON.parse((await readFile(stateFilePath)).toString()); 52 | } catch {} 53 | const tanaAPIHelper = new TanaAPIHelper(token); 54 | 55 | let numAlreadyHandled = 0; 56 | let numUploaded = 0; 57 | const files = await walk(folder); 58 | for (const f of files) { 59 | if (!f.endsWith('.m4a')) { 60 | continue; 61 | } 62 | const fn = f.slice(folder.length); 63 | const result = await processFile(state, tanaAPIHelper, f, fn); 64 | if (!result) { 65 | numAlreadyHandled++; 66 | } else { 67 | numUploaded++; 68 | } 69 | } 70 | const output = []; 71 | if (numUploaded) { 72 | output.push(`Uploaded ${numUploaded} ${numUploaded === 1 ? 'file' : 'files'}.`); 73 | } 74 | if (numAlreadyHandled) { 75 | output.push(`Already uploaded ${numAlreadyHandled} ${numAlreadyHandled === 1 ? 'file' : 'files'}.`); 76 | } 77 | console.log(output.join(' ')); 78 | 79 | await writeFile(stateFilePath, JSON.stringify(state, null, 2)); 80 | } 81 | 82 | run(folder).then(() => process.exit(0)); 83 | -------------------------------------------------------------------------------- /src/types/constants.ts: -------------------------------------------------------------------------------- 1 | export const coreTemplateId = "SYS_T01"; 2 | export const attrDefTemplateId = "SYS_T02"; 3 | -------------------------------------------------------------------------------- /src/types/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const dataType = z.enum(['plain', 'boolean', 'date', 'url', 'reference']); 4 | const idType = z 5 | .string() 6 | .min(7) 7 | .max(16) 8 | .regex(/^[A-Za-z0-9_-]+$/); 9 | 10 | const fieldBase = z.object({ 11 | type: z.literal('field'), 12 | attributeId: idType, 13 | }); 14 | 15 | const plainNodeBase = z.object({ 16 | type: z.literal('node').optional(), 17 | dataType: dataType.exclude(['boolean', 'date', 'reference']).optional(), 18 | name: z.string(), 19 | description: z.string().optional().or(z.literal('')), 20 | supertags: z.array(z.object({ id: idType })).optional(), 21 | }); 22 | 23 | const checkboxNode = z.object({ 24 | type: z.literal('node').optional(), 25 | dataType: z.literal('boolean'), 26 | value: z.boolean(), 27 | }); 28 | 29 | const dateNode = z.object({ 30 | type: z.literal('node').optional(), 31 | dataType: z.literal('date'), 32 | name: z.string(), 33 | }); 34 | 35 | const referenceNode = z.object({ 36 | type: z.literal('node').optional(), 37 | dataType: z.literal('reference'), 38 | id: idType, 39 | }); 40 | 41 | const fileNode = z.object({ 42 | type: z.literal('node').optional(), 43 | dataType: z.literal('file'), 44 | file: z.string(), 45 | contentType: z.string(), 46 | filename: z.string(), 47 | }); 48 | 49 | export type APIPlainNode = z.infer & { 50 | children?: z.infer[]; 51 | }; 52 | 53 | const plainNode: z.ZodType = plainNodeBase.extend({ 54 | children: z.lazy(() => z.array(nodeOrField)).optional(), 55 | }); 56 | 57 | export type APIField = z.infer & { 58 | children?: z.infer[]; 59 | }; 60 | 61 | const field: z.ZodType = fieldBase.extend({ 62 | children: z.lazy(() => z.array(fieldChild)).optional(), 63 | }); 64 | 65 | const node = z.union([plainNode, dateNode, referenceNode, fileNode]); 66 | 67 | const fieldChild = z.union([node, checkboxNode]); 68 | const nodeOrField = z.union([field, node]); 69 | 70 | export const APISchema = z.array(node); 71 | 72 | export type APIDataType = z.infer; 73 | export type APICheckboxNode = z.infer; 74 | export type APIDateNode = z.infer; 75 | export type APIReferenceNode = z.infer; 76 | export type APIFileNode = z.infer; 77 | export type APINode = APIPlainNode | APIDateNode | APIReferenceNode | APIFileNode; 78 | export type APIFieldValue = APINode | APICheckboxNode | APIFileNode; 79 | 80 | export type TanaNode = { 81 | nodeId?: string; 82 | name: string; 83 | description: string; 84 | children?: TanaNode[]; 85 | }; 86 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as readline from 'readline'; 2 | 3 | const rl = readline.createInterface({ 4 | input: process.stdin, 5 | output: process.stdout, 6 | }); 7 | 8 | export async function waitForEnter(term: string = 'Hit to continue'): Promise { 9 | console.log('\n\n'); 10 | return new Promise((resolve) => { 11 | rl.question(term, (answer) => { 12 | console.log('\n\n'); 13 | 14 | // waits for 1sec 15 | setTimeout(() => { 16 | resolve(answer); 17 | }, 1000); 18 | }); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | 5 | "rootDir": "./src", 6 | "declaration": true, 7 | "target": "es2016", 8 | "module": "ESNext", 9 | "experimentalDecorators": true, 10 | "downlevelIteration": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "lib": ["dom", "esnext"], 13 | "esModuleInterop": true, 14 | "sourceMap": false, 15 | "moduleResolution": "node", 16 | 17 | "types": ["node"], 18 | "noImplicitAny": true, 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "forceConsistentCasingInFileNames": true 22 | }, 23 | "include": ["./src"] 24 | } 25 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/node@^18.15.11": 6 | version "18.15.11" 7 | resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" 8 | integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q== 9 | 10 | data-uri-to-buffer@^4.0.0: 11 | version "4.0.0" 12 | resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz#b5db46aea50f6176428ac05b73be39a57701a64b" 13 | integrity sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA== 14 | 15 | fetch-blob@^3.1.2, fetch-blob@^3.1.4: 16 | version "3.1.5" 17 | resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.1.5.tgz#0077bf5f3fcdbd9d75a0b5362f77dbb743489863" 18 | integrity sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg== 19 | dependencies: 20 | node-domexception "^1.0.0" 21 | web-streams-polyfill "^3.0.3" 22 | 23 | formdata-polyfill@^4.0.10: 24 | version "4.0.10" 25 | resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" 26 | integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== 27 | dependencies: 28 | fetch-blob "^3.1.2" 29 | 30 | node-domexception@^1.0.0: 31 | version "1.0.0" 32 | resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" 33 | integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== 34 | 35 | node-fetch@^3.2.10: 36 | version "3.2.10" 37 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.10.tgz#e8347f94b54ae18b57c9c049ef641cef398a85c8" 38 | integrity sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA== 39 | dependencies: 40 | data-uri-to-buffer "^4.0.0" 41 | fetch-blob "^3.1.4" 42 | formdata-polyfill "^4.0.10" 43 | 44 | readline@^1.3.0: 45 | version "1.3.0" 46 | resolved "https://registry.yarnpkg.com/readline/-/readline-1.3.0.tgz#c580d77ef2cfc8752b132498060dc9793a7ac01c" 47 | integrity sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg== 48 | 49 | typescript@^5.0.3: 50 | version "5.0.3" 51 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.3.tgz#fe976f0c826a88d0a382007681cbb2da44afdedf" 52 | integrity sha512-xv8mOEDnigb/tN9PSMTwSEqAnUvkoXMQlicOb0IUVDBSQCgBSaAAROUZYy2IcUy5qU6XajK5jjjO7TMWqBTKZA== 53 | 54 | web-streams-polyfill@^3.0.3: 55 | version "3.2.1" 56 | resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" 57 | integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== 58 | 59 | zod@^3.21.4: 60 | version "3.21.4" 61 | resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db" 62 | integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw== 63 | --------------------------------------------------------------------------------