├── .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 |
130 |
131 |
132 |
133 |
134 |
135 |
Convert from GeoJSON
136 |
137 |
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 |
242 |
243 | upload
244 | - the file being uploaded
245 |
246 |
247 | sourceSrs
248 | (optional) - the original projection
249 |
250 |
251 | targetSrs
252 | (optional) - the target projection
253 |
254 |
255 | forcePlainText
256 | (optional) - force `text/plain` instead of `application/json`
257 |
258 |
259 | rfc7946
260 | (optional) - Create Mapbox-compatible file (RFC7946)
261 |
262 |
263 | callback
264 | (optional) - a JSONP callback function name
265 |
266 |
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 |
275 |
276 | json
277 | - text of the GeoJSON file
278 |
279 |
280 | jsonUrl
281 | - the URL for a remote GeoJSON file
282 |
283 |
284 | outputName
285 | (optional) - the name for the resulting file
286 |
287 |
288 | forceUTF8
289 | (optional) - force utf-8
290 |
291 |
292 | format
293 | (optional) - File format supported by the
294 | ogr2ogr wrapper
295 |
296 |
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 | [](https://github.com/wavded/ogre/actions/workflows/build.yml) [](https://npmjs.com/package/ogre) 
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 |
--------------------------------------------------------------------------------