@walkthru-earth/objex 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # objex
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@walkthru-earth/objex?label=%40walkthru-earth%2Fobjex&color=cb3837)](https://www.npmjs.com/package/@walkthru-earth/objex)
4
+ [![npm](https://img.shields.io/npm/v/@walkthru-earth/objex-utils?label=%40walkthru-earth%2Fobjex-utils&color=cb3837)](https://www.npmjs.com/package/@walkthru-earth/objex-utils)
5
+ [![CI](https://github.com/walkthru-earth/objex/actions/workflows/ci.yml/badge.svg)](https://github.com/walkthru-earth/objex/actions/workflows/ci.yml)
6
+ [![License: CC BY 4.0](https://img.shields.io/badge/license-CC%20BY%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by/4.0/)
7
+
3
8
  Cloud storage explorer that runs entirely in the browser. Connect to S3, Azure, GCS, R2, MinIO -- browse files, query data with SQL, and visualize geospatial formats on interactive maps. No backend required.
4
9
 
5
10
  ```mermaid
@@ -92,12 +97,14 @@ import {
92
97
  | `./utils/geoarrow` | `buildGeoArrowTables`, `normalizeGeomType` |
93
98
  | `./utils/storage-url` | `parseStorageUrl`, `looksLikeUrl` |
94
99
  | `./utils/parquet-metadata` | `readParquetMetadata`, `extractEpsgFromGeoMeta` |
95
- | `./utils/format` | `formatFileSize`, `formatDate`, `getFileExtension` |
100
+ | `./utils/format` | `formatFileSize`, `formatDate`, `formatValue`, `getFileExtension`, `jsonReplacerBigInt` |
96
101
  | `./utils/hex` | `generateHexDump` |
97
102
  | `./utils/column-types` | `classifyType`, `typeColor`, `typeBadgeClass` |
98
103
  | `./file-icons` | `getFileTypeInfo`, `getDuckDbReadFn`, `getViewerKind` |
99
104
  | `./types` | `FileEntry`, `Connection`, `Tab`, `WriteResult`, `Theme` |
100
105
 
106
+ The main export also includes `copyToClipboard`, `handleLoadError`, and shared constants (`WGS84_CODES`, `STORAGE_KEYS`, `DEFAULT_TARGET_CRS`, etc.).
107
+
101
108
  ## Quick Start (Development)
102
109
 
103
110
  ```bash
@@ -8,6 +8,12 @@ import { browser } from '../../stores/browser.svelte.js';
8
8
  import { safeLock } from '../../stores/safelock.svelte.js';
9
9
  import { tabs } from '../../stores/tabs.svelte.js';
10
10
  import type { FileEntry } from '../../types.js';
11
+ import {
12
+ type SortConfig,
13
+ type SortField,
14
+ sortFileEntries,
15
+ toggleSortField
16
+ } from '../../utils/file-sort.js';
11
17
  import { detectZarrMarkers } from '../../utils/zarr.js';
12
18
  import Breadcrumb from './Breadcrumb.svelte';
13
19
  import CreateFolderDialog from './CreateFolderDialog.svelte';
@@ -18,12 +24,8 @@ import RenameDialog from './RenameDialog.svelte';
18
24
  import SearchBar from './SearchBar.svelte';
19
25
  import UploadButton from './UploadButton.svelte';
20
26
 
21
- type SortField = 'name' | 'size' | 'modified' | 'extension';
22
- type SortDirection = 'asc' | 'desc';
23
-
24
27
  let filterQuery = $state('');
25
- let sortField = $state<SortField>('name');
26
- let sortDirection = $state<SortDirection>('asc');
28
+ let sortConfig = $state<SortConfig>({ field: 'name', direction: 'asc' });
27
29
 
28
30
  let deleteDialogOpen = $state(false);
29
31
  let deleteTarget = $state<FileEntry | null>(null);
@@ -50,7 +52,7 @@ function openAsZarr() {
50
52
  }
51
53
 
52
54
  const sortedAndFilteredEntries = $derived.by(() => {
53
- let result = [...browser.entries];
55
+ let result = browser.entries;
54
56
 
55
57
  // Filter
56
58
  if (filterQuery) {
@@ -58,28 +60,7 @@ const sortedAndFilteredEntries = $derived.by(() => {
58
60
  result = result.filter((entry: FileEntry) => entry.name.toLowerCase().includes(q));
59
61
  }
60
62
 
61
- // Sort
62
- const dir = sortDirection === 'asc' ? 1 : -1;
63
- result.sort((a, b) => {
64
- // Directories always come first
65
- if (a.is_dir && !b.is_dir) return -1;
66
- if (!a.is_dir && b.is_dir) return 1;
67
-
68
- switch (sortField) {
69
- case 'name':
70
- return dir * a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
71
- case 'size':
72
- return dir * (a.size - b.size);
73
- case 'modified':
74
- return dir * (a.modified - b.modified);
75
- case 'extension':
76
- return dir * a.extension.localeCompare(b.extension, undefined, { sensitivity: 'base' });
77
- default:
78
- return 0;
79
- }
80
- });
81
-
82
- return result;
63
+ return sortFileEntries(result, sortConfig);
83
64
  });
84
65
 
85
66
  function handleFilter(query: string) {
@@ -91,12 +72,7 @@ function handleNavigate(path: string) {
91
72
  }
92
73
 
93
74
  function handleSort(field: SortField) {
94
- if (sortField === field) {
95
- sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
96
- } else {
97
- sortField = field;
98
- sortDirection = 'asc';
99
- }
75
+ sortConfig = toggleSortField(sortConfig, field);
100
76
  }
101
77
 
102
78
  function handleDelete(entry: FileEntry) {
@@ -152,8 +128,8 @@ function handleRename(entry: FileEntry) {
152
128
  onclick={() => handleSort('name')}
153
129
  >
154
130
  {t('fileBrowser.name')}
155
- {#if sortField === 'name'}
156
- {#if sortDirection === 'asc'}
131
+ {#if sortConfig.field === 'name'}
132
+ {#if sortConfig.direction === 'asc'}
157
133
  <ArrowUp class="size-3" />
158
134
  {:else}
159
135
  <ArrowDown class="size-3" />
@@ -166,8 +142,8 @@ function handleRename(entry: FileEntry) {
166
142
  class="text-muted-foreground hover:text-foreground flex w-20 shrink-0 items-center justify-end gap-1 transition-colors"
167
143
  onclick={() => handleSort('size')}
168
144
  >
169
- {#if sortField === 'size'}
170
- {#if sortDirection === 'asc'}
145
+ {#if sortConfig.field === 'size'}
146
+ {#if sortConfig.direction === 'asc'}
171
147
  <ArrowUp class="size-3" />
172
148
  {:else}
173
149
  <ArrowDown class="size-3" />
@@ -179,8 +155,8 @@ function handleRename(entry: FileEntry) {
179
155
  class="text-muted-foreground hover:text-foreground flex w-24 shrink-0 items-center justify-end gap-1 transition-colors"
180
156
  onclick={() => handleSort('modified')}
181
157
  >
182
- {#if sortField === 'modified'}
183
- {#if sortDirection === 'asc'}
158
+ {#if sortConfig.field === 'modified'}
159
+ {#if sortConfig.direction === 'asc'}
184
160
  <ArrowUp class="size-3" />
185
161
  {:else}
186
162
  <ArrowDown class="size-3" />
@@ -17,7 +17,7 @@ import { getAdapter } from '../../storage/index.js';
17
17
  import { browser } from '../../stores/browser.svelte.js';
18
18
  import { tabs } from '../../stores/tabs.svelte.js';
19
19
  import type { Connection, FileEntry } from '../../types.js';
20
- import { getNativeScheme } from '../../utils/url.js';
20
+ import { getNativeScheme } from '../../utils/cloud-url.js';
21
21
  import { syncUrlParam } from '../../utils/url-state.js';
22
22
 
23
23
  let {
package/dist/index.d.ts CHANGED
@@ -4,17 +4,26 @@ export { buildDuckDbSource, getDuckDbReadFn, getFileTypeInfo, getMimeType, getVi
4
4
  export type { MapQueryHandle, MapQueryResult, QueryEngine, QueryHandle, QueryResult, SchemaField } from './query/engine.js';
5
5
  export { QueryCancelledError } from './query/engine.js';
6
6
  export type { ListPage, StorageAdapter } from './storage/adapter.js';
7
+ export type { ProviderDef, ProviderId, ProviderRegion } from './storage/providers.js';
8
+ export { buildEndpointFromTemplate, buildProviderBaseUrl, getProvider, isGcsProvider, PROVIDER_IDS, PROVIDERS } from './storage/providers.js';
7
9
  export { UrlAdapter } from './storage/url-adapter.js';
8
10
  export type { Connection, ConnectionConfig, FileEntry, Tab, Theme, WriteResult } from './types.js';
9
11
  export { copyToClipboard, wireCodeCopyButtons } from './utils/clipboard.js';
12
+ export { getNativeScheme, resolveCloudUrl, safeDecodeURIComponent } from './utils/cloud-url.js';
10
13
  export type { TypeCategory } from './utils/column-types.js';
11
14
  export { classifyType, typeBadgeClass, typeColor, typeLabel } from './utils/column-types.js';
12
15
  export { handleLoadError } from './utils/error.js';
16
+ export { escapeCsvField, serializeToCsv, serializeToJson } from './utils/export.js';
17
+ export type { SortConfig, SortDirection, SortField } from './utils/file-sort.js';
18
+ export { sortFileEntries, toggleSortField } from './utils/file-sort.js';
13
19
  export { formatDate, formatFileSize, formatValue, getFileExtension, jsonReplacerBigInt } from './utils/format.js';
14
20
  export type { GeoArrowGeomType, GeoArrowResult } from './utils/geoarrow.js';
15
21
  export { buildGeoArrowTables, normalizeGeomType } from './utils/geoarrow.js';
16
22
  export type { HexRow } from './utils/hex.js';
17
23
  export { generateHexDump } from './utils/hex.js';
24
+ export { loadFromStorage, persistToStorage } from './utils/local-storage.js';
25
+ export type { ParsedMarkdownDocument, SqlBlock } from './utils/markdown-sql.js';
26
+ export { interpolateTemplates, markSqlBlocks, parseMarkdownDocument } from './utils/markdown-sql.js';
18
27
  export type { GeoColumnMeta, GeoParquetMeta, ParquetFileMetadata } from './utils/parquet-metadata.js';
19
28
  export { extractBounds, extractEpsgFromGeoMeta, extractGeometryTypes, readParquetMetadata } from './utils/parquet-metadata.js';
20
29
  export type { Defaults, ParsedStorageUrl, StorageProvider } from './utils/storage-url.js';
package/dist/index.js CHANGED
@@ -4,15 +4,24 @@ export { COPY_FEEDBACK_MS, DEFAULT_TARGET_CRS, DUCKDB_INIT_TIMEOUT_MS, LAYER_HUE
4
4
  // File icons registry
5
5
  export { buildDuckDbSource, getDuckDbReadFn, getFileTypeInfo, getMimeType, getViewerKind, isCloudNativeFormat, isQueryable } from './file-icons/index.js';
6
6
  export { QueryCancelledError } from './query/engine.js';
7
+ export { buildEndpointFromTemplate, buildProviderBaseUrl, getProvider, isGcsProvider, PROVIDER_IDS, PROVIDERS } from './storage/providers.js';
7
8
  export { UrlAdapter } from './storage/url-adapter.js';
8
9
  // Clipboard
9
10
  export { copyToClipboard, wireCodeCopyButtons } from './utils/clipboard.js';
11
+ // Cloud URL resolution
12
+ export { getNativeScheme, resolveCloudUrl, safeDecodeURIComponent } from './utils/cloud-url.js';
10
13
  export { classifyType, typeBadgeClass, typeColor, typeLabel } from './utils/column-types.js';
11
14
  // Error handling
12
15
  export { handleLoadError } from './utils/error.js';
16
+ // Data export / serialization
17
+ export { escapeCsvField, serializeToCsv, serializeToJson } from './utils/export.js';
18
+ export { sortFileEntries, toggleSortField } from './utils/file-sort.js';
13
19
  export { formatDate, formatFileSize, formatValue, getFileExtension, jsonReplacerBigInt } from './utils/format.js';
14
20
  export { buildGeoArrowTables, normalizeGeomType } from './utils/geoarrow.js';
15
21
  export { generateHexDump } from './utils/hex.js';
22
+ // localStorage helpers
23
+ export { loadFromStorage, persistToStorage } from './utils/local-storage.js';
24
+ export { interpolateTemplates, markSqlBlocks, parseMarkdownDocument } from './utils/markdown-sql.js';
16
25
  export { extractBounds, extractEpsgFromGeoMeta, extractGeometryTypes, readParquetMetadata } from './utils/parquet-metadata.js';
17
26
  export { describeParseResult, looksLikeUrl, parseStorageUrl } from './utils/storage-url.js';
18
27
  // Utilities
@@ -1,33 +1,7 @@
1
1
  import { STORAGE_KEYS } from '../constants.js';
2
+ import { loadFromStorage, persistToStorage } from '../utils/local-storage.js';
2
3
  import { credentialStore, storeToNative } from './credentials.svelte.js';
3
4
  // ---------------------------------------------------------------------------
4
- // localStorage helpers
5
- // ---------------------------------------------------------------------------
6
- function loadFromLocalStorage() {
7
- if (typeof window === 'undefined')
8
- return [];
9
- try {
10
- const raw = localStorage.getItem(STORAGE_KEYS.CONNECTIONS);
11
- if (raw) {
12
- return JSON.parse(raw);
13
- }
14
- }
15
- catch {
16
- // ignore parse errors
17
- }
18
- return [];
19
- }
20
- function persistToLocalStorage(connections) {
21
- if (typeof window === 'undefined')
22
- return;
23
- try {
24
- localStorage.setItem(STORAGE_KEYS.CONNECTIONS, JSON.stringify(connections));
25
- }
26
- catch {
27
- // ignore storage errors
28
- }
29
- }
30
- // ---------------------------------------------------------------------------
31
5
  // Store
32
6
  // ---------------------------------------------------------------------------
33
7
  function createConnectionsStore() {
@@ -48,7 +22,7 @@ function createConnectionsStore() {
48
22
  async load() {
49
23
  if (loaded)
50
24
  return;
51
- connections = loadFromLocalStorage();
25
+ connections = loadFromStorage(STORAGE_KEYS.CONNECTIONS, []);
52
26
  loaded = true;
53
27
  },
54
28
  /**
@@ -75,7 +49,7 @@ function createConnectionsStore() {
75
49
  rootPrefix: config.rootPrefix
76
50
  };
77
51
  connections = [...connections, conn];
78
- persistToLocalStorage(connections);
52
+ persistToStorage(STORAGE_KEYS.CONNECTIONS, connections);
79
53
  // Store credentials in memory (never persisted to localStorage).
80
54
  if (!config.anonymous) {
81
55
  if (config.sas_token) {
@@ -114,7 +88,7 @@ function createConnectionsStore() {
114
88
  rootPrefix: config.rootPrefix
115
89
  };
116
90
  connections = [...connections];
117
- persistToLocalStorage(connections);
91
+ persistToStorage(STORAGE_KEYS.CONNECTIONS, connections);
118
92
  // Invalidate cached adapter for this connection
119
93
  import('../storage/index.js').then(({ clearAdapterCache }) => clearAdapterCache(id));
120
94
  // Update in-memory credentials.
@@ -148,7 +122,7 @@ function createConnectionsStore() {
148
122
  async remove(id) {
149
123
  const before = connections.length;
150
124
  connections = connections.filter((c) => c.id !== id);
151
- persistToLocalStorage(connections);
125
+ persistToStorage(STORAGE_KEYS.CONNECTIONS, connections);
152
126
  credentialStore.remove(id);
153
127
  // Invalidate cached adapter for this connection
154
128
  import('../storage/index.js').then(({ clearAdapterCache }) => clearAdapterCache(id));
@@ -1,10 +1,5 @@
1
1
  import type { FileEntry } from '../types.js';
2
- export type SortField = 'name' | 'size' | 'modified' | 'extension';
3
- export type SortDirection = 'asc' | 'desc';
4
- export interface SortConfig {
5
- field: SortField;
6
- direction: SortDirection;
7
- }
2
+ import { type SortConfig, type SortField } from '../utils/file-sort.js';
8
3
  export declare const fileStore: {
9
4
  readonly entries: FileEntry[];
10
5
  readonly currentPath: string;
@@ -1,27 +1,4 @@
1
- function sortEntries(entries, config) {
2
- const sorted = [...entries];
3
- const dir = config.direction === 'asc' ? 1 : -1;
4
- sorted.sort((a, b) => {
5
- // Directories always come first
6
- if (a.is_dir && !b.is_dir)
7
- return -1;
8
- if (!a.is_dir && b.is_dir)
9
- return 1;
10
- switch (config.field) {
11
- case 'name':
12
- return dir * a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
13
- case 'size':
14
- return dir * (a.size - b.size);
15
- case 'modified':
16
- return dir * (a.modified - b.modified);
17
- case 'extension':
18
- return dir * a.extension.localeCompare(b.extension, undefined, { sensitivity: 'base' });
19
- default:
20
- return 0;
21
- }
22
- });
23
- return sorted;
24
- }
1
+ import { sortFileEntries, toggleSortField } from '../utils/file-sort.js';
25
2
  function createFilesStore() {
26
3
  let files = $state([]);
27
4
  let currentPath = $state('');
@@ -45,7 +22,7 @@ function createFilesStore() {
45
22
  return sortConfig;
46
23
  },
47
24
  setFiles(entries) {
48
- files = sortEntries(entries, sortConfig);
25
+ files = sortFileEntries(entries, sortConfig);
49
26
  error = null;
50
27
  },
51
28
  setPath(path) {
@@ -58,17 +35,8 @@ function createFilesStore() {
58
35
  error = message;
59
36
  },
60
37
  sort(field) {
61
- if (sortConfig.field === field) {
62
- // Toggle direction if clicking the same field
63
- sortConfig = {
64
- field,
65
- direction: sortConfig.direction === 'asc' ? 'desc' : 'asc'
66
- };
67
- }
68
- else {
69
- sortConfig = { field, direction: 'asc' };
70
- }
71
- files = sortEntries(files, sortConfig);
38
+ sortConfig = toggleSortField(sortConfig, field);
39
+ files = sortFileEntries(files, sortConfig);
72
40
  }
73
41
  };
74
42
  }
@@ -1,31 +1,9 @@
1
1
  import { MAX_QUERY_HISTORY_ENTRIES, STORAGE_KEYS } from '../constants.js';
2
- function loadEntries() {
3
- if (typeof window === 'undefined')
4
- return [];
5
- try {
6
- const raw = localStorage.getItem(STORAGE_KEYS.QUERY_HISTORY);
7
- if (raw)
8
- return JSON.parse(raw);
9
- }
10
- catch {
11
- // ignore parse errors
12
- }
13
- return [];
14
- }
15
- function persistEntries(entries) {
16
- if (typeof window === 'undefined')
17
- return;
18
- try {
19
- localStorage.setItem(STORAGE_KEYS.QUERY_HISTORY, JSON.stringify(entries));
20
- }
21
- catch {
22
- // ignore storage errors
23
- }
24
- }
2
+ import { loadFromStorage, persistToStorage } from '../utils/local-storage.js';
25
3
  function createQueryHistoryStore() {
26
- let entries = $state(loadEntries());
4
+ let entries = $state(loadFromStorage(STORAGE_KEYS.QUERY_HISTORY, []));
27
5
  function save() {
28
- persistEntries(entries);
6
+ persistToStorage(STORAGE_KEYS.QUERY_HISTORY, entries);
29
7
  }
30
8
  return {
31
9
  get entries() {
@@ -1,5 +1,6 @@
1
1
  import { type Locale } from '../i18n/index.svelte.js';
2
2
  import type { Theme } from '../types.js';
3
+ export declare function resolveTheme(theme: Theme): 'light' | 'dark';
3
4
  export declare const settings: {
4
5
  readonly theme: Theme;
5
6
  readonly resolved: "light" | "dark";
@@ -1,36 +1,16 @@
1
1
  import { STORAGE_KEYS } from '../constants.js';
2
2
  import { setLocale } from '../i18n/index.svelte.js';
3
+ import { loadFromStorage, persistToStorage } from '../utils/local-storage.js';
4
+ const SETTINGS_DEFAULTS = { theme: 'system', locale: 'en', featureLimit: 1000 };
3
5
  function loadSettings() {
4
- if (typeof window === 'undefined') {
5
- return { theme: 'system', locale: 'en', featureLimit: 1000 };
6
- }
7
- try {
8
- const raw = localStorage.getItem(STORAGE_KEYS.SETTINGS);
9
- if (raw) {
10
- const parsed = JSON.parse(raw);
11
- return {
12
- theme: parsed.theme ?? 'system',
13
- locale: parsed.locale ?? 'en',
14
- featureLimit: parsed.featureLimit ?? 1000
15
- };
16
- }
17
- }
18
- catch {
19
- // ignore parse errors
20
- }
21
- return { theme: 'system', locale: 'en', featureLimit: 1000 };
22
- }
23
- function persistSettings(settings) {
24
- if (typeof window === 'undefined')
25
- return;
26
- try {
27
- localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
28
- }
29
- catch {
30
- // ignore storage errors
31
- }
6
+ const stored = loadFromStorage(STORAGE_KEYS.SETTINGS, {});
7
+ return {
8
+ theme: stored.theme ?? SETTINGS_DEFAULTS.theme,
9
+ locale: stored.locale ?? SETTINGS_DEFAULTS.locale,
10
+ featureLimit: stored.featureLimit ?? SETTINGS_DEFAULTS.featureLimit
11
+ };
32
12
  }
33
- function resolveTheme(theme) {
13
+ export function resolveTheme(theme) {
34
14
  if (theme !== 'system')
35
15
  return theme;
36
16
  if (typeof window === 'undefined')
@@ -51,7 +31,7 @@ function createSettingsStore() {
51
31
  document.documentElement.lang = initial.locale;
52
32
  }
53
33
  function persist() {
54
- persistSettings({ theme, locale, featureLimit });
34
+ persistToStorage(STORAGE_KEYS.SETTINGS, { theme, locale, featureLimit });
55
35
  }
56
36
  function applyTheme(t) {
57
37
  theme = t;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Cloud storage protocol URL utilities — pure TS, no Svelte dependency.
3
+ *
4
+ * Converts cloud protocol URLs (s3://, gs://) to HTTPS URLs for browser access.
5
+ * Provider-aware native scheme lookup.
6
+ */
7
+ /**
8
+ * Map provider to its native URI scheme prefix.
9
+ * Derived from the registry's `schemes` array (first entry is the primary scheme).
10
+ * Falls back to 's3' for providers without a scheme (S3-compatible).
11
+ */
12
+ export declare function getNativeScheme(provider: string): string;
13
+ /**
14
+ * Safely decode a percent-encoded URI component.
15
+ * Returns the original string if decoding fails (malformed sequences).
16
+ */
17
+ export declare function safeDecodeURIComponent(s: string): string;
18
+ /**
19
+ * Convert a cloud storage protocol URL (s3://, gs://) to an HTTPS URL
20
+ * for browser access. Returns the original URL if already HTTP(S) or unknown.
21
+ *
22
+ * Supported:
23
+ * - `s3://bucket/key` → `https://s3.{region}.amazonaws.com/{bucket}/{key}`
24
+ * (region auto-detected from bucket name when possible)
25
+ * - `gs://bucket/key` → `https://storage.googleapis.com/{bucket}/{key}`
26
+ */
27
+ export declare function resolveCloudUrl(url: string): string;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Cloud storage protocol URL utilities — pure TS, no Svelte dependency.
3
+ *
4
+ * Converts cloud protocol URLs (s3://, gs://) to HTTPS URLs for browser access.
5
+ * Provider-aware native scheme lookup.
6
+ */
7
+ import { buildProviderBaseUrl, PROVIDERS } from '../storage/providers.js';
8
+ /** AWS region pattern — matches prefixes like "us-west-2", "eu-central-1", etc. */
9
+ const AWS_REGION_RE = /^(us|eu|ap|sa|ca|me|af|il)-(north|south|east|west|central|northeast|southeast|northwest|southwest)-\d+/;
10
+ /**
11
+ * Map provider to its native URI scheme prefix.
12
+ * Derived from the registry's `schemes` array (first entry is the primary scheme).
13
+ * Falls back to 's3' for providers without a scheme (S3-compatible).
14
+ */
15
+ export function getNativeScheme(provider) {
16
+ const def = PROVIDERS[provider];
17
+ if (def?.schemes.length)
18
+ return def.schemes[0];
19
+ return 's3';
20
+ }
21
+ /**
22
+ * Safely decode a percent-encoded URI component.
23
+ * Returns the original string if decoding fails (malformed sequences).
24
+ */
25
+ export function safeDecodeURIComponent(s) {
26
+ try {
27
+ return decodeURIComponent(s);
28
+ }
29
+ catch {
30
+ return s;
31
+ }
32
+ }
33
+ /**
34
+ * Convert a cloud storage protocol URL (s3://, gs://) to an HTTPS URL
35
+ * for browser access. Returns the original URL if already HTTP(S) or unknown.
36
+ *
37
+ * Supported:
38
+ * - `s3://bucket/key` → `https://s3.{region}.amazonaws.com/{bucket}/{key}`
39
+ * (region auto-detected from bucket name when possible)
40
+ * - `gs://bucket/key` → `https://storage.googleapis.com/{bucket}/{key}`
41
+ */
42
+ export function resolveCloudUrl(url) {
43
+ // S3 / S3-compatible: s3://, s3a://, s3n://
44
+ const s3Match = url.match(/^s3[an]?:\/\/([^/]+)\/?(.*)$/);
45
+ if (s3Match) {
46
+ const [, bucket, key] = s3Match;
47
+ // Detect region from bucket name (e.g. "us-west-2.opendata.source.coop")
48
+ const regionMatch = bucket.match(AWS_REGION_RE);
49
+ const region = regionMatch ? regionMatch[0] : 'us-east-1';
50
+ const base = buildProviderBaseUrl('s3', '', bucket, region);
51
+ return key ? `${base}/${key}` : base;
52
+ }
53
+ // Google Cloud Storage: gs://, gcs://
54
+ const gcsMatch = url.match(/^gcs?:\/\/([^/]+)\/?(.*)$/);
55
+ if (gcsMatch) {
56
+ const [, bucket, key] = gcsMatch;
57
+ const base = buildProviderBaseUrl('gcs', '', bucket, '');
58
+ return key ? `${base}/${key}` : base;
59
+ }
60
+ return url;
61
+ }
@@ -1,2 +1,22 @@
1
- export declare function exportToCsv(columns: string[], rows: Record<string, any>[], filename: string): void;
2
- export declare function exportToJson(columns: string[], rows: Record<string, any>[], filename: string): void;
1
+ /**
2
+ * Escape a CSV field value per RFC 4180.
3
+ */
4
+ export declare function escapeCsvField(value: string): string;
5
+ /**
6
+ * Serialize column/row data to a CSV string.
7
+ * Pure function — no browser APIs, works in Node.js.
8
+ */
9
+ export declare function serializeToCsv(columns: string[], rows: Record<string, unknown>[]): string;
10
+ /**
11
+ * Serialize column/row data to a formatted JSON string.
12
+ * Pure function — no browser APIs, works in Node.js.
13
+ */
14
+ export declare function serializeToJson(columns: string[], rows: Record<string, unknown>[]): string;
15
+ /**
16
+ * Export data as CSV file (triggers browser download).
17
+ */
18
+ export declare function exportToCsv(columns: string[], rows: Record<string, unknown>[], filename: string): void;
19
+ /**
20
+ * Export data as JSON file (triggers browser download).
21
+ */
22
+ export declare function exportToJson(columns: string[], rows: Record<string, unknown>[], filename: string): void;
@@ -1,3 +1,4 @@
1
+ import { jsonReplacerBigInt } from './format.js';
1
2
  function triggerDownload(content, filename, mimeType) {
2
3
  const blob = new Blob([content], { type: mimeType });
3
4
  const url = URL.createObjectURL(blob);
@@ -9,30 +10,43 @@ function triggerDownload(content, filename, mimeType) {
9
10
  document.body.removeChild(a);
10
11
  URL.revokeObjectURL(url);
11
12
  }
12
- function formatValue(value) {
13
+ /** Format a cell value for export (empty string for null/undefined). */
14
+ function formatCellValue(value) {
13
15
  if (value === null || value === undefined)
14
16
  return '';
15
17
  if (value instanceof Date)
16
18
  return value.toISOString();
19
+ if (typeof value === 'bigint')
20
+ return value.toString();
17
21
  if (typeof value === 'object')
18
- return JSON.stringify(value);
22
+ return JSON.stringify(value, jsonReplacerBigInt);
19
23
  return String(value);
20
24
  }
21
- function escapeCsvField(value) {
25
+ /**
26
+ * Escape a CSV field value per RFC 4180.
27
+ */
28
+ export function escapeCsvField(value) {
22
29
  if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) {
23
30
  return `"${value.replace(/"/g, '""')}"`;
24
31
  }
25
32
  return value;
26
33
  }
27
- export function exportToCsv(columns, rows, filename) {
34
+ /**
35
+ * Serialize column/row data to a CSV string.
36
+ * Pure function — no browser APIs, works in Node.js.
37
+ */
38
+ export function serializeToCsv(columns, rows) {
28
39
  const header = columns.map(escapeCsvField).join(',');
29
40
  const body = rows
30
- .map((row) => columns.map((col) => escapeCsvField(formatValue(row[col]))).join(','))
41
+ .map((row) => columns.map((col) => escapeCsvField(formatCellValue(row[col]))).join(','))
31
42
  .join('\n');
32
- const csv = `${header}\n${body}`;
33
- triggerDownload(csv, filename.endsWith('.csv') ? filename : `${filename}.csv`, 'text/csv;charset=utf-8;');
43
+ return `${header}\n${body}`;
34
44
  }
35
- export function exportToJson(columns, rows, filename) {
45
+ /**
46
+ * Serialize column/row data to a formatted JSON string.
47
+ * Pure function — no browser APIs, works in Node.js.
48
+ */
49
+ export function serializeToJson(columns, rows) {
36
50
  const data = rows.map((row) => {
37
51
  const obj = {};
38
52
  for (const col of columns) {
@@ -46,6 +60,17 @@ export function exportToJson(columns, rows, filename) {
46
60
  }
47
61
  return obj;
48
62
  });
49
- const json = JSON.stringify(data, null, 2);
50
- triggerDownload(json, filename.endsWith('.json') ? filename : `${filename}.json`, 'application/json');
63
+ return JSON.stringify(data, jsonReplacerBigInt, 2);
64
+ }
65
+ /**
66
+ * Export data as CSV file (triggers browser download).
67
+ */
68
+ export function exportToCsv(columns, rows, filename) {
69
+ triggerDownload(serializeToCsv(columns, rows), filename.endsWith('.csv') ? filename : `${filename}.csv`, 'text/csv;charset=utf-8;');
70
+ }
71
+ /**
72
+ * Export data as JSON file (triggers browser download).
73
+ */
74
+ export function exportToJson(columns, rows, filename) {
75
+ triggerDownload(serializeToJson(columns, rows), filename.endsWith('.json') ? filename : `${filename}.json`, 'application/json');
51
76
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Pure file entry sorting — framework-agnostic, works in Node.js.
3
+ */
4
+ import type { FileEntry } from '../types.js';
5
+ export type SortField = 'name' | 'size' | 'modified' | 'extension';
6
+ export type SortDirection = 'asc' | 'desc';
7
+ export interface SortConfig {
8
+ field: SortField;
9
+ direction: SortDirection;
10
+ }
11
+ /**
12
+ * Sort file entries by the given config.
13
+ * Directories always sort before files regardless of sort field.
14
+ * Returns a new array (does not mutate the input).
15
+ */
16
+ export declare function sortFileEntries(entries: FileEntry[], config: SortConfig): FileEntry[];
17
+ /**
18
+ * Toggle sort config: same field flips direction, new field starts ascending.
19
+ */
20
+ export declare function toggleSortField(current: SortConfig, field: SortField): SortConfig;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pure file entry sorting — framework-agnostic, works in Node.js.
3
+ */
4
+ /**
5
+ * Sort file entries by the given config.
6
+ * Directories always sort before files regardless of sort field.
7
+ * Returns a new array (does not mutate the input).
8
+ */
9
+ export function sortFileEntries(entries, config) {
10
+ const sorted = [...entries];
11
+ const dir = config.direction === 'asc' ? 1 : -1;
12
+ sorted.sort((a, b) => {
13
+ // Directories always come first
14
+ if (a.is_dir && !b.is_dir)
15
+ return -1;
16
+ if (!a.is_dir && b.is_dir)
17
+ return 1;
18
+ switch (config.field) {
19
+ case 'name':
20
+ return dir * a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
21
+ case 'size':
22
+ return dir * (a.size - b.size);
23
+ case 'modified':
24
+ return dir * (a.modified - b.modified);
25
+ case 'extension':
26
+ return dir * a.extension.localeCompare(b.extension, undefined, { sensitivity: 'base' });
27
+ default:
28
+ return 0;
29
+ }
30
+ });
31
+ return sorted;
32
+ }
33
+ /**
34
+ * Toggle sort config: same field flips direction, new field starts ascending.
35
+ */
36
+ export function toggleSortField(current, field) {
37
+ if (current.field === field) {
38
+ return { field, direction: current.direction === 'asc' ? 'desc' : 'asc' };
39
+ }
40
+ return { field, direction: 'asc' };
41
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Generic localStorage helpers with SSR safety.
3
+ *
4
+ * Used by connection, settings, and query-history stores to avoid
5
+ * repeating the same load/persist/try-catch/SSR-guard pattern.
6
+ */
7
+ /**
8
+ * Load a JSON value from localStorage.
9
+ * Returns `defaultValue` on SSR, missing key, or parse error.
10
+ */
11
+ export declare function loadFromStorage<T>(key: string, defaultValue: T): T;
12
+ /**
13
+ * Persist a JSON-serializable value to localStorage.
14
+ * Silently no-ops on SSR or storage errors (quota, private browsing).
15
+ */
16
+ export declare function persistToStorage(key: string, value: unknown): void;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Generic localStorage helpers with SSR safety.
3
+ *
4
+ * Used by connection, settings, and query-history stores to avoid
5
+ * repeating the same load/persist/try-catch/SSR-guard pattern.
6
+ */
7
+ /**
8
+ * Load a JSON value from localStorage.
9
+ * Returns `defaultValue` on SSR, missing key, or parse error.
10
+ */
11
+ export function loadFromStorage(key, defaultValue) {
12
+ if (typeof window === 'undefined')
13
+ return defaultValue;
14
+ try {
15
+ const raw = localStorage.getItem(key);
16
+ if (raw)
17
+ return JSON.parse(raw);
18
+ }
19
+ catch {
20
+ // ignore parse errors
21
+ }
22
+ return defaultValue;
23
+ }
24
+ /**
25
+ * Persist a JSON-serializable value to localStorage.
26
+ * Silently no-ops on SSR or storage errors (quota, private browsing).
27
+ */
28
+ export function persistToStorage(key, value) {
29
+ if (typeof window === 'undefined')
30
+ return;
31
+ try {
32
+ localStorage.setItem(key, JSON.stringify(value));
33
+ }
34
+ catch {
35
+ // ignore storage errors
36
+ }
37
+ }
@@ -4,12 +4,6 @@ import type { Tab } from '../types.js';
4
4
  * Works for any viewer that needs an HTTP-accessible URL (COG, PMTiles, Zarr, etc.)
5
5
  */
6
6
  export declare function buildHttpsUrl(tab: Tab): string;
7
- /**
8
- * Map provider to its native URI scheme prefix.
9
- * Derived from the registry's `schemes` array (first entry is the primary scheme).
10
- * Falls back to 's3' for providers without a scheme (S3-compatible).
11
- */
12
- export declare function getNativeScheme(provider: string): string;
13
7
  /**
14
8
  * Build a provider-native protocol URL (s3://bucket/path, sj://bucket/path, etc.).
15
9
  */
@@ -29,13 +23,3 @@ export declare function buildDuckDbUrl(tab: Tab): string;
29
23
  * False for authenticated S3 (needs signed URLs or blob download via adapter).
30
24
  */
31
25
  export declare function canStreamDirectly(tab: Tab): boolean;
32
- /**
33
- * Convert a cloud storage protocol URL (s3://, gs://) to an HTTPS URL
34
- * for browser access. Returns the original URL if already HTTP(S) or unknown.
35
- *
36
- * Supported:
37
- * - `s3://bucket/key` → `https://s3.{region}.amazonaws.com/{bucket}/{key}`
38
- * (region auto-detected from bucket name when possible, e.g. "us-west-2.opendata.source.coop")
39
- * - `gs://bucket/key` → `https://storage.googleapis.com/{bucket}/{key}`
40
- */
41
- export declare function resolveCloudUrl(url: string): string;
package/dist/utils/url.js CHANGED
@@ -1,6 +1,7 @@
1
- import { buildProviderBaseUrl, PROVIDERS } from '../storage/providers.js';
1
+ import { buildProviderBaseUrl } from '../storage/providers.js';
2
2
  import { connections } from '../stores/connections.svelte.js';
3
3
  import { credentialStore } from '../stores/credentials.svelte.js';
4
+ import { getNativeScheme, safeDecodeURIComponent } from './cloud-url.js';
4
5
  /**
5
6
  * Build an HTTPS URL for a tab's file.
6
7
  * Works for any viewer that needs an HTTP-accessible URL (COG, PMTiles, Zarr, etc.)
@@ -19,17 +20,6 @@ export function buildHttpsUrl(tab) {
19
20
  }
20
21
  return `${buildProviderBaseUrl(conn.provider, conn.endpoint, conn.bucket, conn.region)}/${cleanPath}`;
21
22
  }
22
- /**
23
- * Map provider to its native URI scheme prefix.
24
- * Derived from the registry's `schemes` array (first entry is the primary scheme).
25
- * Falls back to 's3' for providers without a scheme (S3-compatible).
26
- */
27
- export function getNativeScheme(provider) {
28
- const def = PROVIDERS[provider];
29
- if (def?.schemes.length)
30
- return def.schemes[0];
31
- return 's3';
32
- }
33
23
  /**
34
24
  * Build a provider-native protocol URL (s3://bucket/path, sj://bucket/path, etc.).
35
25
  */
@@ -68,14 +58,6 @@ export function buildDuckDbUrl(tab) {
68
58
  const rawPath = safeDecodeURIComponent(tab.path.replace(/^\//, ''));
69
59
  return `s3://${conn.bucket}/${rawPath}`;
70
60
  }
71
- function safeDecodeURIComponent(s) {
72
- try {
73
- return decodeURIComponent(s);
74
- }
75
- catch {
76
- return s;
77
- }
78
- }
79
61
  /**
80
62
  * Check if a tab's file can be loaded directly via HTTPS URL (streaming).
81
63
  * True for URL-sourced tabs, anonymous buckets, and Azure (SAS token in URL).
@@ -105,37 +87,3 @@ function appendAzureSas(url, connectionId) {
105
87
  const sep = url.includes('?') ? '&' : '?';
106
88
  return `${url}${sep}${cleanToken}`;
107
89
  }
108
- // ---------------------------------------------------------------------------
109
- // Cloud protocol URL → HTTPS conversion
110
- // ---------------------------------------------------------------------------
111
- /** AWS region pattern — matches prefixes like "us-west-2", "eu-central-1", etc. */
112
- const AWS_REGION_RE = /^(us|eu|ap|sa|ca|me|af|il)-(north|south|east|west|central|northeast|southeast|northwest|southwest)-\d+/;
113
- /**
114
- * Convert a cloud storage protocol URL (s3://, gs://) to an HTTPS URL
115
- * for browser access. Returns the original URL if already HTTP(S) or unknown.
116
- *
117
- * Supported:
118
- * - `s3://bucket/key` → `https://s3.{region}.amazonaws.com/{bucket}/{key}`
119
- * (region auto-detected from bucket name when possible, e.g. "us-west-2.opendata.source.coop")
120
- * - `gs://bucket/key` → `https://storage.googleapis.com/{bucket}/{key}`
121
- */
122
- export function resolveCloudUrl(url) {
123
- // S3 / S3-compatible: s3://, s3a://, s3n://
124
- const s3Match = url.match(/^s3[an]?:\/\/([^/]+)\/?(.*)$/);
125
- if (s3Match) {
126
- const [, bucket, key] = s3Match;
127
- // Detect region from bucket name (e.g. "us-west-2.opendata.source.coop")
128
- const regionMatch = bucket.match(AWS_REGION_RE);
129
- const region = regionMatch ? regionMatch[0] : 'us-east-1';
130
- const base = buildProviderBaseUrl('s3', '', bucket, region);
131
- return key ? `${base}/${key}` : base;
132
- }
133
- // Google Cloud Storage: gs://, gcs://
134
- const gcsMatch = url.match(/^gcs?:\/\/([^/]+)\/?(.*)$/);
135
- if (gcsMatch) {
136
- const [, bucket, key] = gcsMatch;
137
- const base = buildProviderBaseUrl('gcs', '', bucket, '');
138
- return key ? `${base}/${key}` : base;
139
- }
140
- return url;
141
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkthru-earth/objex",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Svelte 5 components and utilities for exploring geospatial object storage — S3, GCS, Azure, R2",
5
5
  "author": "Youssef Harby <yharby@walkthru.earth>",
6
6
  "license": "CC-BY-4.0",
@@ -123,13 +123,14 @@
123
123
  }
124
124
  },
125
125
  "devDependencies": {
126
- "@biomejs/biome": "^2.4.6",
126
+ "@biomejs/biome": "^2.4.7",
127
127
  "@changesets/changelog-github": "^0.5.2",
128
128
  "@changesets/cli": "^2.30.0",
129
+ "@fontsource/cairo": "^5.2.7",
129
130
  "@internationalized/date": "^3.12.0",
130
131
  "@lucide/svelte": "^0.561.0",
131
132
  "@sveltejs/adapter-static": "^3.0.10",
132
- "@sveltejs/kit": "^2.53.4",
133
+ "@sveltejs/kit": "^2.55.0",
133
134
  "@sveltejs/package": "^2.5.7",
134
135
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
135
136
  "@tailwindcss/forms": "^0.5.11",
@@ -137,12 +138,11 @@
137
138
  "@tailwindcss/vite": "^4.2.1",
138
139
  "bits-ui": "^2.16.3",
139
140
  "clsx": "^2.1.1",
140
- "@fontsource/cairo": "^5.2.7",
141
- "lefthook": "^2.1.3",
141
+ "lefthook": "^2.1.4",
142
142
  "paneforge": "^1.0.2",
143
- "posthog-js": "^1.360.0",
143
+ "posthog-js": "^1.360.2",
144
144
  "publint": "^0.3.18",
145
- "svelte": "^5.53.9",
145
+ "svelte": "^5.53.12",
146
146
  "svelte-check": "^4.4.5",
147
147
  "tailwind-merge": "^3.5.0",
148
148
  "tailwind-variants": "^3.2.2",
@@ -152,16 +152,16 @@
152
152
  "vite": "^7.3.1"
153
153
  },
154
154
  "dependencies": {
155
- "@babylonjs/core": "^8.54.1",
156
- "@babylonjs/loaders": "^8.54.1",
155
+ "@babylonjs/core": "^8.55.3",
156
+ "@babylonjs/loaders": "^8.55.3",
157
157
  "@carbonplan/zarr-layer": "^0.3.1",
158
158
  "@codemirror/autocomplete": "^6.20.1",
159
- "@codemirror/commands": "^6.10.2",
159
+ "@codemirror/commands": "^6.10.3",
160
160
  "@codemirror/lang-sql": "^6.10.0",
161
161
  "@codemirror/language": "^6.12.2",
162
- "@codemirror/state": "^6.5.4",
162
+ "@codemirror/state": "^6.6.0",
163
163
  "@codemirror/theme-one-dark": "^6.1.3",
164
- "@codemirror/view": "^6.39.16",
164
+ "@codemirror/view": "^6.40.0",
165
165
  "@deck.gl/core": "^9.2.11",
166
166
  "@deck.gl/geo-layers": "^9.2.11",
167
167
  "@deck.gl/layers": "^9.2.11",
@@ -182,12 +182,12 @@
182
182
  "chart.js": "^4.5.1",
183
183
  "deck.gl": "^9.2.11",
184
184
  "flatgeobuf": "^4.4.0",
185
- "geotiff": "^3.0.4",
185
+ "geotiff": "^3.0.5",
186
186
  "geotiff-geokeys-to-proj4": "^2024.4.13",
187
187
  "hyparquet": "^1.25.1",
188
188
  "hyparquet-compressors": "^1.1.1",
189
189
  "lz-string": "^1.5.0",
190
- "maplibre-gl": "^5.19.0",
190
+ "maplibre-gl": "^5.20.1",
191
191
  "marked": "^17.0.4",
192
192
  "mermaid": "^11.13.0",
193
193
  "pbf": "^4.0.1",