├── .github ├── release-drafter.yml └── workflows │ ├── build.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── cli.ts ├── eslint.config.js ├── index.html ├── index.ts ├── index_test.ts ├── license ├── package.json ├── readme.md ├── testdata └── sample.shp.zip ├── tsconfig.json ├── typings.d.ts └── vitest.config.ts /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | template: | 4 | $CHANGES 5 | category-template: "#### $TITLE" 6 | change-template: "* #$NUMBER - $TITLE (@$AUTHOR)" 7 | categories: 8 | - title: "Breaking changes" 9 | label: "breaking" 10 | - title: "Enhancements" 11 | label: "enhancement" 12 | - title: "Bug fixes" 13 | label: "bug" 14 | - title: "Maintenance" 15 | label: "chore" 16 | 17 | version-resolver: 18 | major: 19 | labels: 20 | - "breaking" 21 | minor: 22 | labels: 23 | - "enhancement" 24 | patch: 25 | labels: 26 | - "bug" 27 | - "chore" 28 | 29 | exclude-labels: 30 | - "skip-changelog" 31 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | container: 11 | image: ghcr.io/osgeo/gdal:ubuntu-full-latest 12 | options: --user 1001 13 | strategy: 14 | matrix: 15 | node: ["18", "20", "22"] 16 | name: Node v${{ matrix.node }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node }} 22 | - run: npm install 23 | - run: npm run fmt-check 24 | - run: npm run lint 25 | - run: npm run test 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release-draft 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | update_release_draft: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: release-drafter/release-drafter@master 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: stale-issues-prs 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v3 12 | with: 13 | stale-issue-message: "This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days." 14 | stale-pr-message: "This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days." 15 | close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity." 16 | days-before-stale: 30 17 | days-before-close: 5 18 | stale-issue-label: stale 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | ogr_* 3 | .DS_Store 4 | coverage 5 | dist 6 | pnpm-lock.yaml 7 | package-lock.json 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | testdata 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "bracketSpacing": false, 4 | "plugins": ["prettier-plugin-organize-imports"] 5 | } 6 | -------------------------------------------------------------------------------- /cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import {Ogre} from "./" 3 | import {version} from "./package.json" 4 | 5 | let args = process.argv.slice(2) 6 | 7 | let usage = 8 | "" + 9 | "\n\x1b[1mUsage\x1b[0m: ogre [options]\n" + 10 | "\n" + 11 | "\x1b[1mOptions:\x1b[0m\n" + 12 | " -h, --help help\n" + 13 | " -p, --port port number (default 3000)\n" + 14 | " -v, --version version number\n" + 15 | " -t, --timeout timeout before ogre kills a job in ms (default 15000)\n" + 16 | " -l, --limit byte limit for uploads (default 50000000)\n" 17 | 18 | let port = 3000 19 | let timeout = 15000 20 | let limit = 50000000 21 | 22 | let arg 23 | while (args.length) { 24 | arg = args.shift() 25 | switch (arg) { 26 | case "-h": 27 | case "--help": 28 | console.log(usage) 29 | process.exit(0) 30 | break 31 | 32 | case "-v": 33 | case "--version": 34 | console.log("ogre " + version) 35 | process.exit(0) 36 | break 37 | 38 | case "-p": 39 | case "--port": 40 | port = Number(args.shift()) 41 | break 42 | 43 | case "-t": 44 | case "--timeout": 45 | timeout = Number(args.shift()) 46 | break 47 | 48 | case "-l": 49 | case "--limit": 50 | limit = Number(args.shift()) 51 | break 52 | 53 | default: 54 | } 55 | } 56 | 57 | let ogre = new Ogre({port, timeout, limit}) 58 | ogre.start() 59 | console.log("Ogre (%s) ready. Port %d", version, port) 60 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js" 2 | import prettier from "eslint-plugin-prettier/recommended" 3 | import ts from "typescript-eslint" 4 | 5 | export default ts.config({ 6 | extends: [js.configs.recommended, ...ts.configs.recommended, prettier], 7 | rules: { 8 | "prefer-const": 0, 9 | eqeqeq: [2, "smart"], 10 | "@typescript-eslint/no-explicit-any": 0, 11 | "@typescript-eslint/no-unused-vars": [ 12 | "error", 13 | { 14 | argsIgnorePattern: "^_", 15 | caughtErrorsIgnorePattern: "^_", 16 | destructuredArrayIgnorePattern: "^_", 17 | varsIgnorePattern: "^_", 18 | ignoreRestSiblings: true, 19 | }, 20 | ], 21 | }, 22 | ignores: ["dist/**"], 23 | }) 24 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Ogre - ogr2ogr web client 5 | 6 | 7 | 11 | 15 | 29 | 30 | 31 |
32 | 33 | 34 |

35 | Ogre 36 |

37 |

38 | Ogre is a web client (service) that translates spatial files into 39 | GeoJSON using the 40 | ogr2ogr command line tool 41 | for use in web applications and frameworks. 42 |

43 |
44 |
45 |
46 |
47 |
48 |

Convert to GeoJSON

49 |
50 |
51 |
58 |
59 | 62 |
63 | 69 |

70 | Must be a supported format. See below. 71 |

72 |
73 |
74 |
75 | 78 |
79 | 85 |
86 |
87 |
88 | 91 |
92 | 98 |
99 |
100 |
101 |
102 |
103 | 107 |
108 |
109 |
110 |
111 | 115 |
116 |
117 |
118 |
119 |
120 | 123 |

124 | Note: GeoJSON can only support one layer 125 |

126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |

Convert from GeoJSON

136 |
137 |
138 |
144 |
145 | 148 |
149 | 163 |
164 |
165 |
166 | 169 |
170 | 177 |
178 |
179 |
180 | 183 |
184 | 190 |
191 |
192 |
193 | 196 |
197 | 203 |
204 |
205 |
206 |
207 |
208 | 212 |
213 |
214 |
215 |
216 |
217 | 220 |

221 | Note: Shapefiles can only support one geometry type 222 |

223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |

Accessing Ogre using a POST request

231 |

232 | You can perform Ogre transformations directly by making a HTTP POST 233 | request: 234 |

235 |

236 | Convert to GeoJSON 237 |

238 |

239 | http://ogre.adc4gis.com/convert with the following params: 240 |

241 | 267 |

268 | Convert from GeoJSON to Shapefile (or specified format) 269 |

270 |

271 | http://ogre.adc4gis.com/convertJson with 272 | one of the following params: 273 |

274 | 297 |

298 | Where can I watch the project status, report issues, contribute, or fork 299 | the code? 300 |

301 |

302 | Issues and feature requests can be submitted 303 | here 304 | and to watch, fork and/or contribute to the project, 305 | visit the github page 306 | . 307 |

308 |

309 | "Orc Head" drawing by 310 | Jason J. Patterson 311 |

312 |
313 | 314 | 315 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import {serve} from "@hono/node-server" 2 | import {ErrorHandler, Handler, Hono, NotFoundHandler} from "hono" 3 | import {bodyLimit} from "hono/body-limit" 4 | import {cors} from "hono/cors" 5 | import {BlankEnv, BlankSchema} from "hono/types" 6 | import {randomBytes} from "node:crypto" 7 | import {unlink, writeFile} from "node:fs/promises" 8 | import {tmpdir} from "node:os" 9 | import {Readable} from "node:stream" 10 | import {ogr2ogr} from "ogr2ogr" 11 | import index from "./index.html?raw" 12 | 13 | export interface OgreOpts { 14 | port?: number 15 | timeout?: number 16 | limit?: number 17 | } 18 | 19 | interface UploadOpts { 20 | [key: string]: string | File 21 | targetSrs: string 22 | upload: File 23 | sourceSrs: string 24 | rfc7946: string 25 | forcePlainText: string 26 | forceDownload: string 27 | callback: string 28 | } 29 | 30 | const TMP_DIR = tmpdir() 31 | 32 | export class Ogre { 33 | app: Hono 34 | private timeout: number 35 | private port: number 36 | private limit: number 37 | 38 | constructor({ 39 | port = 3000, 40 | timeout = 150000, 41 | limit = 50000000, 42 | }: OgreOpts = {}) { 43 | this.port = port 44 | this.timeout = timeout 45 | this.limit = limit 46 | 47 | let app = (this.app = new Hono()) 48 | app.notFound(this.notFound()) 49 | app.onError(this.serverError()) 50 | 51 | app.options("/", this.heartbeat()) 52 | app.get("/", this.index()) 53 | app.use(cors(), bodyLimit({maxSize: this.limit})) 54 | app.post("/convert", this.convert()) 55 | app.post("/convertJson", this.convertJson()) 56 | } 57 | 58 | start(): void { 59 | serve({fetch: this.app.fetch, port: this.port}) 60 | } 61 | 62 | private notFound = (): NotFoundHandler => (c) => { 63 | return c.json({error: "Not found"}, 404) 64 | } 65 | 66 | private serverError = (): ErrorHandler => (er, c) => { 67 | console.error(er.stack) 68 | return c.json({error: true, message: er.message}, 500) 69 | } 70 | 71 | private heartbeat = (): Handler => async () => new Response() 72 | 73 | private index = (): Handler => async (c) => c.html(index) 74 | 75 | private convert = (): Handler => async (c) => { 76 | let { 77 | upload, 78 | targetSrs, 79 | sourceSrs, 80 | rfc7946, 81 | forcePlainText, 82 | forceDownload, 83 | callback, 84 | }: UploadOpts = await c.req.parseBody() 85 | if (!upload) { 86 | return c.json({error: true, msg: "No file provided"}, 400) 87 | } 88 | 89 | let opts = { 90 | timeout: this.timeout, 91 | options: [] as string[], 92 | maxBuffer: this.limit * 10, 93 | } 94 | 95 | if (targetSrs) opts.options.push("-t_srs", targetSrs) 96 | if (sourceSrs) opts.options.push("-s_srs", sourceSrs) 97 | if (rfc7946 != null) opts.options.push("-lco", "RFC7946=YES") 98 | 99 | c.header( 100 | "content-type", 101 | forcePlainText != null 102 | ? "text/plain; charset=utf-8" 103 | : "application/json; charset=utf-8", 104 | ) 105 | 106 | if (forceDownload != null) { 107 | c.header("content-disposition", "attachment;") 108 | } 109 | 110 | let path = TMP_DIR + "/" + randomBytes(16).toString("hex") + upload.name 111 | let body: string 112 | try { 113 | let buf = await upload.arrayBuffer() 114 | await writeFile(path, Buffer.from(buf)) 115 | let {data} = await ogr2ogr(path, opts) 116 | if (callback) { 117 | body = callback + "(" + JSON.stringify(data) + ")" 118 | } else { 119 | body = JSON.stringify(data) 120 | } 121 | } finally { 122 | unlink(path).catch((er) => console.error("unlink error", er.message)) 123 | } 124 | return c.body(body) 125 | } 126 | 127 | private convertJson = (): Handler => async (c) => { 128 | let {jsonUrl, json, outputName, format, forceUTF8}: Record = 129 | await c.req.parseBody() 130 | if (!jsonUrl && !json) { 131 | return c.json({error: true, msg: "No json provided"}, 400) 132 | } 133 | 134 | let data 135 | if (json) { 136 | try { 137 | data = JSON.parse(json) 138 | } catch (_er) { 139 | return c.json({error: true, msg: "Invalid json provided"}, 400) 140 | } 141 | } 142 | 143 | let input = jsonUrl || data 144 | let output = outputName || "ogre" 145 | 146 | let opts = { 147 | format: (format || "ESRI Shapefile").toLowerCase(), 148 | timeout: this.timeout, 149 | options: [] as string[], 150 | maxBuffer: this.limit * 10, 151 | } 152 | 153 | if (outputName) opts.options.push("-nln", outputName) 154 | if (forceUTF8 != null) opts.options.push("-lco", "ENCODING=UTF-8") 155 | 156 | let out = await ogr2ogr(input, opts) 157 | c.header( 158 | "content-disposition", 159 | "attachment; filename=" + output + out.extname, 160 | ) 161 | 162 | if (out.stream) { 163 | return c.body(Readable.toWeb(out.stream) as ReadableStream) 164 | } else if (out.text) { 165 | return c.text(out.text) 166 | } else { 167 | return c.json(out.data) 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /index_test.ts: -------------------------------------------------------------------------------- 1 | import {serve} from "@hono/node-server" 2 | import request from "supertest" 3 | import {assert, test} from "vitest" 4 | import {Ogre, OgreOpts} from "./" 5 | 6 | test(async () => { 7 | let table: { 8 | opts?: OgreOpts 9 | method?: string 10 | url: string 11 | status: number 12 | body?: string 13 | upload?: string 14 | contents?: RegExp 15 | }[] = [ 16 | {method: "OPTIONS", url: "/", status: 200}, 17 | {url: "/not-found", status: 404}, 18 | {url: "/", status: 200}, 19 | 20 | {method: "POST", url: "/convert", status: 400, contents: /no file/i}, 21 | { 22 | method: "POST", 23 | url: "/convert", 24 | status: 200, 25 | upload: "./testdata/sample.shp.zip", 26 | }, 27 | { 28 | opts: {limit: 5}, 29 | method: "POST", 30 | url: "/convert", 31 | status: 500, 32 | upload: "./testdata/sample.shp.zip", 33 | }, 34 | 35 | {method: "POST", url: "/convertJson", status: 400, contents: /no json/i}, 36 | { 37 | method: "POST", 38 | url: "/convertJson", 39 | status: 200, 40 | body: `json=${JSON.stringify({ 41 | type: "FeatureCollection", 42 | features: [ 43 | { 44 | type: "Feature", 45 | geometry: {type: "Point", coordinates: [102.0, 0.5]}, 46 | properties: {prop0: "value0"}, 47 | }, 48 | ], 49 | })}`, 50 | }, 51 | { 52 | opts: {limit: 5}, 53 | method: "POST", 54 | url: "/convertJson", 55 | status: 500, 56 | body: `json=${JSON.stringify({ 57 | type: "FeatureCollection", 58 | features: [ 59 | { 60 | type: "Feature", 61 | geometry: {type: "Point", coordinates: [102.0, 0.5]}, 62 | properties: {prop0: "value0"}, 63 | }, 64 | ], 65 | })}`, 66 | }, 67 | ] 68 | 69 | for (let tt of table) { 70 | let ogre = new Ogre(tt.opts) 71 | let app = serve({fetch: ogre.app.fetch}) 72 | let req 73 | 74 | switch (tt.method) { 75 | case "OPTIONS": 76 | req = request(app).options(tt.url) 77 | break 78 | case "POST": 79 | req = request(app).post(tt.url).send(tt.body) 80 | break 81 | default: 82 | // 'GET' 83 | req = request(app).get(tt.url) 84 | break 85 | } 86 | 87 | if (tt.upload) { 88 | req = req.attach("upload", tt.upload) 89 | } 90 | 91 | let res = await req 92 | 93 | assert.equal(res.status, tt.status, tt.url) 94 | if (tt.contents) { 95 | assert.match(res.text, tt.contents) 96 | } 97 | } 98 | }) 99 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Marc Harter 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ogre", 3 | "version": "5.0.3", 4 | "description": "ogr2ogr web client", 5 | "keywords": [ 6 | "ogr2ogr", 7 | "GIS", 8 | "GeoJSON" 9 | ], 10 | "author": "Marc Harter ", 11 | "repository": { 12 | "type": "git", 13 | "url": "http://github.com/wavded/ogre.git" 14 | }, 15 | "homepage": "http://github.com/wavded/ogre", 16 | "bin": { 17 | "ogre": "dist/cli.cjs" 18 | }, 19 | "type": "module", 20 | "scripts": { 21 | "prepublishOnly": "pnpm build", 22 | "build": "tsup-node cli.ts", 23 | "start": "pnpm build && dist/cli.cjs", 24 | "test": "vitest run --silent", 25 | "lint": "tsc --noEmit && eslint .", 26 | "fmt": "prettier --write .", 27 | "fmt-check": "prettier --check ." 28 | }, 29 | "tsup": { 30 | "loader": { 31 | ".html": "text" 32 | }, 33 | "clean": true 34 | }, 35 | "dependencies": { 36 | "@hono/node-server": "^1.14.1", 37 | "hono": "^4.7.7", 38 | "ogr2ogr": "6.0.0" 39 | }, 40 | "devDependencies": { 41 | "@eslint/js": "^9.24.0", 42 | "@types/archiver": "^6.0.3", 43 | "@types/node": "^22.14.1", 44 | "@types/supertest": "^6.0.3", 45 | "eslint": "^9.24.0", 46 | "eslint-config-prettier": "^10.1.2", 47 | "eslint-plugin-prettier": "^5.2.6", 48 | "prettier": "^3.5.3", 49 | "prettier-plugin-organize-imports": "^4.1.0", 50 | "supertest": "^7.1.0", 51 | "tsup": "^8.4.0", 52 | "tsx": "^4.19.3", 53 | "typescript": "^5.8.3", 54 | "typescript-eslint": "^8.30.1", 55 | "vitest": "^3.1.1" 56 | }, 57 | "engines": { 58 | "node": ">=18" 59 | }, 60 | "pnpm": { 61 | "onlyBuiltDependencies": [ 62 | "esbuild" 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![build](https://github.com/wavded/ogre/actions/workflows/build.yml/badge.svg)](https://github.com/wavded/ogre/actions/workflows/build.yml) [![NPM](https://img.shields.io/npm/v/ogre.svg)](https://npmjs.com/package/ogre) ![NPM Downloads](https://img.shields.io/npm/dt/ogre.svg) 2 | 3 | Ogre is a web frontend and API for the [ogr2ogr][2] module. See a [live demo here][3]. 4 | 5 | ## Installation 6 | 7 | 1. [Install GDAL tools][1] (includes the `ogr2ogr` command line tool) 8 | 9 | 2. Install package: 10 | 11 | ```sh 12 | npm install -g ogre 13 | ``` 14 | 15 | ## Usage 16 | 17 | To run the app: 18 | 19 | ```sh 20 | ogre -p 3000 21 | ``` 22 | 23 | Then visit in a your favorite browser. 24 | 25 | Options include: 26 | 27 | ``` 28 | Usage: ogre [options] 29 | 30 | Options: 31 | -h, --help help 32 | -p, --port port number (default 3000) 33 | -v, --version version number 34 | -t, --timeout timeout before ogre kills a job in ms (default 15000) 35 | -l, --limit byte limit for uploads (default 50000000) 36 | ``` 37 | 38 | [1]: https://gdal.org/download.html 39 | [2]: https://github.com/wavded/ogr2ogr 40 | [3]: https://ogre.adc4gis.com 41 | [4]: https://github.com/wavded/ogre/wiki 42 | -------------------------------------------------------------------------------- /testdata/sample.shp.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wavded/ogre/897fc66690c72d5ebf6cc1cc5d3b1d2baf45ed72/testdata/sample.shp.zip -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "esnext", 5 | "strict": true, 6 | "isolatedModules": true, 7 | "skipLibCheck": true, 8 | "noImplicitReturns": true, 9 | "noUnusedLocals": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "resolveJsonModule": true 14 | }, 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html?raw" { 2 | const value: string 3 | export default value 4 | } 5 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from "vitest/config" 2 | 3 | // https://vitest.dev/config/ 4 | export default defineConfig({ 5 | test: { 6 | include: ["**/*_test.[jt]s"], 7 | testTimeout: 30000, 8 | }, 9 | }) 10 | --------------------------------------------------------------------------------