nuxt-google-sheets-import 0.1.6 → 0.1.7
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 +97 -104
- package/dist/module.json +1 -1
- package/dist/module.mjs +3 -0
- package/dist/runtime/components/GoogleSheetsImportExport.d.vue.ts +3 -0
- package/dist/runtime/components/GoogleSheetsImportExport.vue +196 -0
- package/dist/runtime/components/GoogleSheetsImportExport.vue.d.ts +3 -0
- package/dist/runtime/components/GoogleSheetsImportSchemaGuide.vue +28 -48
- package/dist/runtime/pages/google-sheets-import.vue +34 -3
- package/dist/runtime/server/utils/transform.js +1 -42
- package/dist/runtime/utils/clipboard.d.ts +15 -0
- package/dist/runtime/utils/clipboard.js +12 -0
- package/dist/runtime/utils/delimited.d.ts +2 -0
- package/dist/runtime/utils/delimited.js +9 -0
- package/dist/runtime/utils/pathMapping.d.ts +4 -0
- package/dist/runtime/utils/pathMapping.js +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nuxt-google-sheets-import
|
|
2
2
|
|
|
3
3
|
Schema-driven Google Sheets importer for Nuxt Content.
|
|
4
4
|
|
|
5
5
|
## Status
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Nuxt module for schema-driven Google Sheets import/export workflows with Nuxt Content.
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
@@ -13,28 +13,30 @@ This package scaffold is extracted from a working local module and is ready for
|
|
|
13
13
|
- Writes frontmatter markdown, JSON, or YAML output
|
|
14
14
|
- Supports overwrite strategies (`overwrite`, `skip`, `overwrite-frontmatter`)
|
|
15
15
|
- Exposes UI components and composables for import workflow
|
|
16
|
+
- Includes a built-in `/google-sheets-import` page with tabbed setup/import/export flow
|
|
17
|
+
- Supports TSV clipboard copy for Google Sheets-friendly paste
|
|
16
18
|
|
|
17
|
-
## Install
|
|
19
|
+
## Install
|
|
18
20
|
|
|
19
21
|
```bash
|
|
20
|
-
pnpm add
|
|
22
|
+
pnpm add nuxt-google-sheets-import
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
##
|
|
25
|
+
## Configuration
|
|
24
26
|
|
|
25
27
|
```ts
|
|
26
28
|
// nuxt.config.ts
|
|
27
29
|
export default defineNuxtConfig({
|
|
28
|
-
modules: ['
|
|
30
|
+
modules: ['nuxt-google-sheets-import'],
|
|
29
31
|
googleSheetsImport: {
|
|
30
32
|
apiBase: '/api/google-sheets-import',
|
|
31
33
|
googleApiKeyRuntimeKey: 'googleApiKey',
|
|
32
|
-
defaultContentDir: 'content'
|
|
34
|
+
defaultContentDir: 'content/data'
|
|
33
35
|
}
|
|
34
36
|
})
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
Collection type (`page` vs `data`) is derived from your Nuxt Content `content.config.ts`
|
|
39
|
+
Collection type (`page` vs `data`) is derived from your Nuxt Content `content.config.ts` collections.
|
|
38
40
|
|
|
39
41
|
## Environment
|
|
40
42
|
|
|
@@ -42,74 +44,32 @@ Collection type (`page` vs `data`) is derived from your Nuxt Content `content.co
|
|
|
42
44
|
NUXT_GOOGLE_API_KEY=your_google_sheets_api_key
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
##
|
|
47
|
+
## Quick Start
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
1. Add the module and runtime env var.
|
|
50
|
+
2. Define your Zod `schemas` export in `~/utils/googleSheetImportSchemas.ts`.
|
|
51
|
+
3. Ensure matching Nuxt Content collections exist in `content.config.ts`.
|
|
52
|
+
4. Start dev server and open `/google-sheets-import`.
|
|
53
|
+
5. Use tabs in order:
|
|
54
|
+
- `Setup Google Sheet`: confirm schema headers and copy as needed.
|
|
55
|
+
- `Import data`: load sheet rows and write content files.
|
|
56
|
+
- `Export data`: query existing records, copy TSV for Sheets, or download CSV.
|
|
48
57
|
|
|
49
|
-
|
|
50
|
-
> API-key access is best suited to non-sensitive sheets that are intentionally shared as `Anyone with the link`.
|
|
51
|
-
> For private or sensitive spreadsheets, prefer OAuth 2.0 or a service account flow instead of API-key access.
|
|
52
|
-
|
|
53
|
-
### 1) Set sheet permissions (Google Sheets)
|
|
54
|
-
|
|
55
|
-
1. Open your sheet in Google Sheets.
|
|
56
|
-
2. Click `Share`.
|
|
57
|
-
3. Under `General access`, set to `Anyone with the link`.
|
|
58
|
-
4. Set role to `Viewer`.
|
|
59
|
-
5. Copy the spreadsheet ID from the URL:
|
|
60
|
-
- `https://docs.google.com/spreadsheets/d/<SPREADSHEET_ID>/edit`
|
|
61
|
-
|
|
62
|
-
If your Google Workspace policy blocks public link sharing, API key access will fail. In that case you need OAuth/service-account based auth (not part of this module yet).
|
|
63
|
-
|
|
64
|
-
### 2) Create API key (Google Cloud Console)
|
|
65
|
-
|
|
66
|
-
1. Open Google Cloud Console: https://console.cloud.google.com/
|
|
67
|
-
2. Create/select a project.
|
|
68
|
-
3. Enable Google Sheets API:
|
|
69
|
-
- https://console.cloud.google.com/apis/library/sheets.googleapis.com
|
|
70
|
-
4. Create credentials (API key):
|
|
71
|
-
- https://console.cloud.google.com/apis/credentials
|
|
72
|
-
- Click `Create credentials` → `API key`
|
|
73
|
-
5. Restrict the key (recommended):
|
|
74
|
-
- **API restrictions**: `Restrict key` → select `Google Sheets API`
|
|
75
|
-
- **Application restrictions**:
|
|
76
|
-
- Server usage: `IP addresses` (recommended for backend)
|
|
77
|
-
- Browser-only usage: `HTTP referrers` (if applicable)
|
|
78
|
-
6. Put the key into `NUXT_GOOGLE_API_KEY`.
|
|
79
|
-
|
|
80
|
-
### 3) Quick verification
|
|
81
|
-
|
|
82
|
-
Call your module endpoint with a known sheet ID and confirm it returns tab metadata:
|
|
58
|
+
Quick endpoint check:
|
|
83
59
|
|
|
84
60
|
`GET /api/google-sheets-import/sheets?spreadsheetId=<SPREADSHEET_ID>`
|
|
85
61
|
|
|
86
|
-
### 4) Troubleshooting (common errors)
|
|
87
|
-
|
|
88
|
-
- `403 PERMISSION_DENIED` / `The caller does not have permission`
|
|
89
|
-
- The sheet is not publicly readable with link.
|
|
90
|
-
- Fix: set `Share` → `General access` → `Anyone with the link` + `Viewer`.
|
|
91
|
-
|
|
92
|
-
- `403 API key not valid` or `API has not been used in project`
|
|
93
|
-
- The key is wrong, restricted to the wrong API, or Sheets API is not enabled.
|
|
94
|
-
- Fix: enable `Google Sheets API` and ensure key restriction includes it.
|
|
95
|
-
|
|
96
|
-
- `403 Requests from this referrer/IP are blocked`
|
|
97
|
-
- Your key application restrictions do not match where requests come from.
|
|
98
|
-
- Fix: update key restrictions (`IP addresses` for server use is preferred).
|
|
99
|
-
|
|
100
|
-
- `404 Requested entity was not found`
|
|
101
|
-
- Spreadsheet ID is incorrect or malformed.
|
|
102
|
-
- Fix: copy ID from `https://docs.google.com/spreadsheets/d/<SPREADSHEET_ID>/edit`.
|
|
103
|
-
|
|
104
|
-
- `400 Unable to parse range`
|
|
105
|
-
- Invalid A1 range (for example typo in sheet tab or columns).
|
|
106
|
-
- Fix: verify tab name and use ranges like `A:Z`.
|
|
107
|
-
|
|
108
62
|
## Exported runtime
|
|
109
63
|
|
|
110
|
-
- Components: `GoogleSheetsImportSource`, `GoogleSheetsImportExecute`, `GoogleSheetsImportSchemaGuide`
|
|
64
|
+
- Components: `GoogleSheetsImportSource`, `GoogleSheetsImportExecute`, `GoogleSheetsImportSchemaGuide`, `GoogleSheetsImportExport`
|
|
111
65
|
- Composables: `useGoogleSheetsImport`, `useGoogleSheetsImportWorkflow`
|
|
112
66
|
|
|
67
|
+
The module adds a route at `/google-sheets-import` with `UTabs` for:
|
|
68
|
+
|
|
69
|
+
- `Setup Google Sheet` (schema guide)
|
|
70
|
+
- `Import data` (sheet source/import flow)
|
|
71
|
+
- `Export data` (export existing content records)
|
|
72
|
+
|
|
113
73
|
### Schema helper component
|
|
114
74
|
|
|
115
75
|
Use `GoogleSheetsImportSchemaGuide` to let editors choose a schema and see the expected Google Sheet column headers.
|
|
@@ -126,7 +86,7 @@ Benefits:
|
|
|
126
86
|
- For `page` collections, shows Nuxt Content built-in page override fields and allows copying them separately
|
|
127
87
|
- Supports two copy modes:
|
|
128
88
|
- line-by-line copy
|
|
129
|
-
-
|
|
89
|
+
- tab-separated row copy (pastes horizontally into Google Sheets)
|
|
130
90
|
|
|
131
91
|
Optional prop:
|
|
132
92
|
|
|
@@ -144,11 +104,12 @@ Use these value patterns when filling sheets:
|
|
|
144
104
|
- `string[]` (scalar array): `foo, bar, baz` in a single cell
|
|
145
105
|
- `object[]` (array of objects): use indexed headers like `items[0].name`, `items[0].price`
|
|
146
106
|
|
|
147
|
-
## Schema
|
|
107
|
+
## Schema Source
|
|
148
108
|
|
|
149
|
-
|
|
109
|
+
Define a `schemas` export in `~/utils/googleSheetImportSchemas.ts`.
|
|
110
|
+
The module auto-imports this as `googleSheetsImportSchemas` for app and server runtime use.
|
|
150
111
|
|
|
151
|
-
|
|
112
|
+
Example:
|
|
152
113
|
|
|
153
114
|
```ts
|
|
154
115
|
export const schemas = {
|
|
@@ -157,27 +118,12 @@ export const schemas = {
|
|
|
157
118
|
}
|
|
158
119
|
```
|
|
159
120
|
|
|
160
|
-
##
|
|
161
|
-
|
|
162
|
-
The playground includes:
|
|
163
|
-
|
|
164
|
-
- `playground/utils/googleSheetImportSchemas.ts` (tiny local schema registry)
|
|
165
|
-
- `POST /api/google-sheets-import/values-smoke` (local transform/validation payload)
|
|
166
|
-
- `playground/scripts/smoke.mjs` (calls `values-smoke`, then module `write` for `frontmatter`, `json`, `yaml`)
|
|
167
|
-
|
|
168
|
-
Run with Nuxt dev server active:
|
|
169
|
-
|
|
170
|
-
```bash
|
|
171
|
-
pnpm --dir packages/nuxt-google-sheets-import smoke:playground
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
Optional custom base URL:
|
|
121
|
+
## Export Behavior
|
|
175
122
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
```
|
|
123
|
+
- `Copy for Google Sheets` in `GoogleSheetsImportExport` copies **TSV** to the clipboard for reliable Sheets paste.
|
|
124
|
+
- `Download .csv` provides quoted CSV output for file-based workflows.
|
|
179
125
|
|
|
180
|
-
## Additional API
|
|
126
|
+
## Additional API Endpoint
|
|
181
127
|
|
|
182
128
|
- `GET {apiBase}/schema-columns`
|
|
183
129
|
- Query: `schema?`
|
|
@@ -187,31 +133,78 @@ SMOKE_BASE_URL=http://localhost:3000 pnpm --dir packages/nuxt-google-sheets-impo
|
|
|
187
133
|
- `collectionType`: `page | data | unknown`
|
|
188
134
|
- `pageOverrideColumns`: Nuxt Content page override fields (when `collectionType === 'page'`)
|
|
189
135
|
|
|
136
|
+
## Google Setup (Permissions + API Key)
|
|
137
|
+
|
|
138
|
+
This module reads Google Sheets using an API key, so the sheet must be publicly readable.
|
|
139
|
+
|
|
140
|
+
> Security note:
|
|
141
|
+
> API-key access is best suited to non-sensitive sheets that are intentionally shared as `Anyone with the link`.
|
|
142
|
+
> For private or sensitive spreadsheets, prefer OAuth 2.0 or a service account flow instead of API-key access.
|
|
143
|
+
|
|
144
|
+
### 1) Set sheet permissions (Google Sheets)
|
|
145
|
+
|
|
146
|
+
1. Open your sheet in Google Sheets.
|
|
147
|
+
2. Click `Share`.
|
|
148
|
+
3. Under `General access`, set to `Anyone with the link`.
|
|
149
|
+
4. Set role to `Viewer`.
|
|
150
|
+
5. Copy the spreadsheet ID from the URL:
|
|
151
|
+
- `https://docs.google.com/spreadsheets/d/<SPREADSHEET_ID>/edit`
|
|
152
|
+
|
|
153
|
+
If your Google Workspace policy blocks public link sharing, API key access will fail. In that case you need OAuth/service-account based auth (not part of this module yet).
|
|
154
|
+
|
|
155
|
+
### 2) Create API key (Google Cloud Console)
|
|
156
|
+
|
|
157
|
+
1. Open Google Cloud Console: https://console.cloud.google.com/
|
|
158
|
+
2. Create/select a project.
|
|
159
|
+
3. Enable Google Sheets API:
|
|
160
|
+
- https://console.cloud.google.com/apis/library/sheets.googleapis.com
|
|
161
|
+
4. Create credentials (API key):
|
|
162
|
+
- https://console.cloud.google.com/apis/credentials
|
|
163
|
+
- Click `Create credentials` -> `API key`
|
|
164
|
+
5. Restrict the key (recommended):
|
|
165
|
+
- **API restrictions**: `Restrict key` -> select `Google Sheets API`
|
|
166
|
+
- **Application restrictions**:
|
|
167
|
+
- Server usage: `IP addresses` (recommended for backend)
|
|
168
|
+
- Browser-only usage: `HTTP referrers` (if applicable)
|
|
169
|
+
6. Put the key into `NUXT_GOOGLE_API_KEY`.
|
|
170
|
+
|
|
171
|
+
### 3) Troubleshooting (common errors)
|
|
172
|
+
|
|
173
|
+
- `403 PERMISSION_DENIED` / `The caller does not have permission`
|
|
174
|
+
- The sheet is not publicly readable with link.
|
|
175
|
+
- Fix: set `Share` -> `General access` -> `Anyone with the link` + `Viewer`.
|
|
176
|
+
|
|
177
|
+
- `403 API key not valid` or `API has not been used in project`
|
|
178
|
+
- The key is wrong, restricted to the wrong API, or Sheets API is not enabled.
|
|
179
|
+
- Fix: enable `Google Sheets API` and ensure key restriction includes it.
|
|
180
|
+
|
|
181
|
+
- `403 Requests from this referrer/IP are blocked`
|
|
182
|
+
- Your key application restrictions do not match where requests come from.
|
|
183
|
+
- Fix: update key restrictions (`IP addresses` for server use is preferred).
|
|
184
|
+
|
|
185
|
+
- `404 Requested entity was not found`
|
|
186
|
+
- Spreadsheet ID is incorrect or malformed.
|
|
187
|
+
- Fix: copy ID from `https://docs.google.com/spreadsheets/d/<SPREADSHEET_ID>/edit`.
|
|
188
|
+
|
|
189
|
+
- `400 Unable to parse range`
|
|
190
|
+
- Invalid A1 range (for example typo in sheet tab or columns).
|
|
191
|
+
- Fix: verify tab name and use ranges like `A:Z`.
|
|
192
|
+
|
|
190
193
|
## Publish checklist
|
|
191
194
|
|
|
192
195
|
- Add playground integration tests for `/values` and `/write`
|
|
193
196
|
- Add CI (`lint`, `typecheck`, `build`) and release workflow
|
|
194
197
|
- Verify Nuxt 4 peer compatibility matrix
|
|
195
198
|
|
|
196
|
-
##
|
|
199
|
+
## Release
|
|
197
200
|
|
|
198
201
|
```bash
|
|
199
|
-
|
|
202
|
+
npm run release
|
|
200
203
|
```
|
|
201
204
|
|
|
202
|
-
|
|
205
|
+
If needed, authenticate and publish manually:
|
|
203
206
|
|
|
204
207
|
```bash
|
|
205
208
|
npm login
|
|
206
|
-
|
|
209
|
+
npm publish --access public
|
|
207
210
|
```
|
|
208
|
-
|
|
209
|
-
Or use one-command release scripts (bumps version + checks + publishes):
|
|
210
|
-
|
|
211
|
-
```bash
|
|
212
|
-
npm login
|
|
213
|
-
pnpm --dir packages/nuxt-google-sheets-import release:patch
|
|
214
|
-
# or: release:minor / release:major
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
These scripts use `npm version --no-git-tag-version`, so they update `package.json` version without creating a git tag/commit.
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
|
+
import { googleSheetsImportSchemas, queryCollection, useAsyncData, useToast } from "#imports";
|
|
4
|
+
import { copyTextWithSuccessToast } from "../utils/clipboard";
|
|
5
|
+
import { toCsvRow, toTsvRow } from "../utils/delimited";
|
|
6
|
+
import { flattenRecordToStringMap } from "../utils/pathMapping";
|
|
7
|
+
const toast = useToast();
|
|
8
|
+
const queryCollectionLoose = queryCollection;
|
|
9
|
+
const selectedSchema = ref("");
|
|
10
|
+
const {
|
|
11
|
+
data: rowsData,
|
|
12
|
+
pending,
|
|
13
|
+
error: loadError,
|
|
14
|
+
status,
|
|
15
|
+
execute,
|
|
16
|
+
clear
|
|
17
|
+
} = useAsyncData(
|
|
18
|
+
"google-sheets-import-export-records",
|
|
19
|
+
async () => {
|
|
20
|
+
if (!selectedSchema.value) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const result = await queryCollectionLoose(selectedSchema.value).all();
|
|
24
|
+
return result;
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
immediate: false,
|
|
28
|
+
default: () => []
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
const rows = computed(() => rowsData.value ?? []);
|
|
32
|
+
const error = computed(() => loadError.value?.message ?? "");
|
|
33
|
+
watch(selectedSchema, () => {
|
|
34
|
+
clear();
|
|
35
|
+
});
|
|
36
|
+
const schemaMap = computed(() => googleSheetsImportSchemas ?? {});
|
|
37
|
+
const schemaOptions = computed(() => Object.keys(schemaMap.value).sort((left, right) => left.localeCompare(right)).map((schema) => ({ label: schema, value: schema })));
|
|
38
|
+
const headers = computed(() => {
|
|
39
|
+
const all = /* @__PURE__ */ new Set();
|
|
40
|
+
for (const row of rows.value) {
|
|
41
|
+
flattenRecordToStringMap(row).forEach((_, key) => all.add(key));
|
|
42
|
+
}
|
|
43
|
+
return Array.from(all).sort((left, right) => left.localeCompare(right));
|
|
44
|
+
});
|
|
45
|
+
const csvText = computed(() => {
|
|
46
|
+
if (!headers.value.length) {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
const lines = [];
|
|
50
|
+
lines.push(toCsvRow(headers.value));
|
|
51
|
+
for (const row of rows.value) {
|
|
52
|
+
const flat = flattenRecordToStringMap(row);
|
|
53
|
+
const values = headers.value.map((header) => flat.get(header) ?? "");
|
|
54
|
+
lines.push(toCsvRow(values));
|
|
55
|
+
}
|
|
56
|
+
return `${lines.join("\n")}
|
|
57
|
+
`;
|
|
58
|
+
});
|
|
59
|
+
const tsvText = computed(() => {
|
|
60
|
+
if (!headers.value.length) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
const lines = [];
|
|
64
|
+
lines.push(toTsvRow(headers.value));
|
|
65
|
+
for (const row of rows.value) {
|
|
66
|
+
const flat = flattenRecordToStringMap(row);
|
|
67
|
+
const values = headers.value.map((header) => flat.get(header) ?? "");
|
|
68
|
+
lines.push(toTsvRow(values));
|
|
69
|
+
}
|
|
70
|
+
return `${lines.join("\n")}
|
|
71
|
+
`;
|
|
72
|
+
});
|
|
73
|
+
const previewText = computed(() => {
|
|
74
|
+
if (!csvText.value) {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
return csvText.value.split("\n").slice(0, 11).join("\n");
|
|
78
|
+
});
|
|
79
|
+
async function loadRecords() {
|
|
80
|
+
if (!selectedSchema.value) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await execute();
|
|
84
|
+
if (status.value === "success") {
|
|
85
|
+
toast.add({
|
|
86
|
+
title: "Export ready",
|
|
87
|
+
description: `Loaded ${rows.value.length} row(s) from collection ${selectedSchema.value}.`,
|
|
88
|
+
color: "success"
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function copyCsv() {
|
|
93
|
+
await copyTextWithSuccessToast({
|
|
94
|
+
text: tsvText.value,
|
|
95
|
+
toast,
|
|
96
|
+
description: "Tab-separated rows copied for Google Sheets paste.",
|
|
97
|
+
title: "Copied"
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function downloadCsv() {
|
|
101
|
+
if (!csvText.value) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const blob = new Blob([csvText.value], { type: "text/csv;charset=utf-8" });
|
|
105
|
+
const url = URL.createObjectURL(blob);
|
|
106
|
+
const link = document.createElement("a");
|
|
107
|
+
link.href = url;
|
|
108
|
+
link.download = `${selectedSchema.value || "export"}.csv`;
|
|
109
|
+
link.click();
|
|
110
|
+
URL.revokeObjectURL(url);
|
|
111
|
+
}
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<template>
|
|
115
|
+
<div class="space-y-4">
|
|
116
|
+
<UAlert
|
|
117
|
+
title="Export Existing Content"
|
|
118
|
+
description="Load records from a Nuxt Content collection and export them as CSV for Google Sheets."
|
|
119
|
+
color="neutral"
|
|
120
|
+
variant="subtle"
|
|
121
|
+
/>
|
|
122
|
+
|
|
123
|
+
<UAlert
|
|
124
|
+
v-if="error"
|
|
125
|
+
title="Could not load collection"
|
|
126
|
+
:description="error"
|
|
127
|
+
color="error"
|
|
128
|
+
variant="subtle"
|
|
129
|
+
/>
|
|
130
|
+
|
|
131
|
+
<UFormField
|
|
132
|
+
label="Collection schema"
|
|
133
|
+
name="schema"
|
|
134
|
+
description="Pick the schema/collection to export."
|
|
135
|
+
>
|
|
136
|
+
<USelect
|
|
137
|
+
v-model="selectedSchema"
|
|
138
|
+
:items="schemaOptions"
|
|
139
|
+
value-key="value"
|
|
140
|
+
class="w-full max-w-sm"
|
|
141
|
+
icon="i-heroicons-cube-20-solid"
|
|
142
|
+
/>
|
|
143
|
+
</UFormField>
|
|
144
|
+
|
|
145
|
+
<div class="flex flex-wrap gap-2">
|
|
146
|
+
<UButton
|
|
147
|
+
label="Load records"
|
|
148
|
+
icon="i-heroicons-arrow-down-tray-20-solid"
|
|
149
|
+
:disabled="!selectedSchema"
|
|
150
|
+
:loading="pending"
|
|
151
|
+
@click="loadRecords"
|
|
152
|
+
/>
|
|
153
|
+
<UButton
|
|
154
|
+
label="Copy for Google Sheets"
|
|
155
|
+
icon="i-heroicons-clipboard-document-20-solid"
|
|
156
|
+
color="neutral"
|
|
157
|
+
variant="subtle"
|
|
158
|
+
:disabled="!tsvText"
|
|
159
|
+
@click="copyCsv"
|
|
160
|
+
/>
|
|
161
|
+
<UButton
|
|
162
|
+
label="Download .csv"
|
|
163
|
+
icon="i-heroicons-document-arrow-down-20-solid"
|
|
164
|
+
color="neutral"
|
|
165
|
+
variant="subtle"
|
|
166
|
+
:disabled="!csvText"
|
|
167
|
+
@click="downloadCsv"
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<UAlert
|
|
172
|
+
v-if="status === 'success'"
|
|
173
|
+
title="Export prepared"
|
|
174
|
+
:description="`${rows.length} row(s), ${headers.length} column(s).`"
|
|
175
|
+
color="success"
|
|
176
|
+
variant="subtle"
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
<UCollapsible
|
|
180
|
+
v-if="previewText"
|
|
181
|
+
class="flex flex-col gap-2 w-full"
|
|
182
|
+
>
|
|
183
|
+
<UButton
|
|
184
|
+
label="Preview CSV (first 10 rows)"
|
|
185
|
+
color="neutral"
|
|
186
|
+
variant="subtle"
|
|
187
|
+
trailing-icon="i-lucide-chevron-down"
|
|
188
|
+
block
|
|
189
|
+
/>
|
|
190
|
+
|
|
191
|
+
<template #content>
|
|
192
|
+
<pre class="text-xs whitespace-pre-wrap">{{ previewText }}</pre>
|
|
193
|
+
</template>
|
|
194
|
+
</UCollapsible>
|
|
195
|
+
</div>
|
|
196
|
+
</template>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { computed, onMounted, ref, watch } from "vue";
|
|
3
3
|
import { useToast } from "#imports";
|
|
4
4
|
import { useGoogleSheetsImport } from "../composables/useGoogleSheetsImport";
|
|
5
|
+
import { copyTextWithSuccessToast } from "../utils/clipboard";
|
|
6
|
+
import { toTsvRow } from "../utils/delimited";
|
|
5
7
|
const props = defineProps({
|
|
6
8
|
initialSchema: { type: String, required: false, default: "" }
|
|
7
9
|
});
|
|
@@ -61,58 +63,36 @@ onMounted(async () => {
|
|
|
61
63
|
await loadColumns(selectedSchema.value);
|
|
62
64
|
}
|
|
63
65
|
});
|
|
64
|
-
function copyColumns() {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
navigator.clipboard.writeText(content);
|
|
70
|
-
toast.add({
|
|
71
|
-
title: "Copied",
|
|
66
|
+
async function copyColumns() {
|
|
67
|
+
await copyTextWithSuccessToast({
|
|
68
|
+
text: columns.value.join("\n"),
|
|
69
|
+
toast,
|
|
72
70
|
description: "Column names copied to clipboard.",
|
|
73
|
-
|
|
71
|
+
title: "Copied"
|
|
74
72
|
});
|
|
75
73
|
}
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
function copyColumnsCsv() {
|
|
83
|
-
const content = csvRow(columns.value);
|
|
84
|
-
if (!content) {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
navigator.clipboard.writeText(content);
|
|
88
|
-
toast.add({
|
|
89
|
-
title: "Copied",
|
|
90
|
-
description: "Column names copied as a CSV row.",
|
|
91
|
-
color: "success"
|
|
74
|
+
async function copyColumnsTsv() {
|
|
75
|
+
await copyTextWithSuccessToast({
|
|
76
|
+
text: toTsvRow(columns.value),
|
|
77
|
+
toast,
|
|
78
|
+
description: "Column names copied as a tab-separated row for Google Sheets.",
|
|
79
|
+
title: "Copied"
|
|
92
80
|
});
|
|
93
81
|
}
|
|
94
|
-
function copyPageOverrideColumns() {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
navigator.clipboard.writeText(content);
|
|
100
|
-
toast.add({
|
|
101
|
-
title: "Copied",
|
|
82
|
+
async function copyPageOverrideColumns() {
|
|
83
|
+
await copyTextWithSuccessToast({
|
|
84
|
+
text: pageOverrideColumns.value.join("\n"),
|
|
85
|
+
toast,
|
|
102
86
|
description: "Page override column names copied to clipboard.",
|
|
103
|
-
|
|
87
|
+
title: "Copied"
|
|
104
88
|
});
|
|
105
89
|
}
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
toast.add({
|
|
113
|
-
title: "Copied",
|
|
114
|
-
description: "Page override column names copied as a CSV row.",
|
|
115
|
-
color: "success"
|
|
90
|
+
async function copyPageOverrideColumnsTsv() {
|
|
91
|
+
await copyTextWithSuccessToast({
|
|
92
|
+
text: toTsvRow(pageOverrideColumns.value),
|
|
93
|
+
toast,
|
|
94
|
+
description: "Page override column names copied as a tab-separated row for Google Sheets.",
|
|
95
|
+
title: "Copied"
|
|
116
96
|
});
|
|
117
97
|
}
|
|
118
98
|
</script>
|
|
@@ -170,11 +150,11 @@ function copyPageOverrideColumnsCsv() {
|
|
|
170
150
|
@click="copyColumns"
|
|
171
151
|
/>
|
|
172
152
|
<UButton
|
|
173
|
-
label="Copy as
|
|
153
|
+
label="Copy as tab-separated row"
|
|
174
154
|
icon="i-heroicons-table-cells-20-solid"
|
|
175
155
|
color="neutral"
|
|
176
156
|
variant="subtle"
|
|
177
|
-
@click="
|
|
157
|
+
@click="copyColumnsTsv"
|
|
178
158
|
/>
|
|
179
159
|
</div>
|
|
180
160
|
<pre class="text-xs whitespace-pre-wrap">{{ columns.join("\n") }}</pre>
|
|
@@ -200,11 +180,11 @@ function copyPageOverrideColumnsCsv() {
|
|
|
200
180
|
@click="copyPageOverrideColumns"
|
|
201
181
|
/>
|
|
202
182
|
<UButton
|
|
203
|
-
label="Copy overrides as
|
|
183
|
+
label="Copy overrides as tab-separated row"
|
|
204
184
|
icon="i-heroicons-table-cells-20-solid"
|
|
205
185
|
color="neutral"
|
|
206
186
|
variant="subtle"
|
|
207
|
-
@click="
|
|
187
|
+
@click="copyPageOverrideColumnsTsv"
|
|
208
188
|
/>
|
|
209
189
|
</div>
|
|
210
190
|
<pre class="text-xs whitespace-pre-wrap">{{ pageOverrideColumns.join("\n") }}</pre>
|
|
@@ -4,11 +4,42 @@ const sheetsList = ref([
|
|
|
4
4
|
{ id: "1NKS0cTX6u5urtgQ3Q4Z2motiR2-9JmyPxcd05yVc1bc", label: "Metzner" },
|
|
5
5
|
{ id: "1tGZCEoiikXfg3mOpfVWWTS1SSSsj18xv6Z3owrnnt4s", label: "Example Sheet" }
|
|
6
6
|
]);
|
|
7
|
+
const items = ref([
|
|
8
|
+
{
|
|
9
|
+
label: "Setup Google Sheet",
|
|
10
|
+
icon: "i-lucide-layout-dashboard",
|
|
11
|
+
slot: "sheet"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
label: "Import data",
|
|
15
|
+
icon: "i-lucide-upload",
|
|
16
|
+
slot: "import"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
label: "Export data",
|
|
20
|
+
icon: "i-lucide-download",
|
|
21
|
+
slot: "export"
|
|
22
|
+
}
|
|
23
|
+
]);
|
|
7
24
|
</script>
|
|
8
25
|
|
|
9
26
|
<template>
|
|
10
|
-
<UContainer>
|
|
11
|
-
<
|
|
12
|
-
|
|
27
|
+
<UContainer class="max-w-36">
|
|
28
|
+
<UTabs
|
|
29
|
+
:items="items"
|
|
30
|
+
variant="link"
|
|
31
|
+
color="neutral"
|
|
32
|
+
class="w-full"
|
|
33
|
+
>
|
|
34
|
+
<template #sheet>
|
|
35
|
+
<GoogleSheetsImportSchemaGuide :initial-schema="'Example Sheet'" />
|
|
36
|
+
</template>
|
|
37
|
+
<template #import>
|
|
38
|
+
<GoogleSheetsImportSource :google-sheets="sheetsList" />
|
|
39
|
+
</template>
|
|
40
|
+
<template #export>
|
|
41
|
+
<GoogleSheetsImportExport />
|
|
42
|
+
</template>
|
|
43
|
+
</UTabs>
|
|
13
44
|
</UContainer>
|
|
14
45
|
</template>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { parseHeaderPath, setDeep } from "../../utils/pathMapping.js";
|
|
2
3
|
function isZodType(value) {
|
|
3
4
|
return typeof value === "object" && value !== null && "_def" in value;
|
|
4
5
|
}
|
|
@@ -47,48 +48,6 @@ function hasWrapper(schema, wrapper) {
|
|
|
47
48
|
}
|
|
48
49
|
return false;
|
|
49
50
|
}
|
|
50
|
-
function parseHeaderPath(header) {
|
|
51
|
-
const tokens = [];
|
|
52
|
-
const parts = header.split(".");
|
|
53
|
-
for (const part of parts) {
|
|
54
|
-
const matches = part.matchAll(/([^[]+)|(\[(\d+)\])/g);
|
|
55
|
-
for (const match of matches) {
|
|
56
|
-
if (match[1]) {
|
|
57
|
-
tokens.push(match[1]);
|
|
58
|
-
}
|
|
59
|
-
if (match[3]) {
|
|
60
|
-
tokens.push(Number.parseInt(match[3], 10));
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return tokens;
|
|
65
|
-
}
|
|
66
|
-
function setDeep(target, path, value) {
|
|
67
|
-
if (!path.length) {
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
let cursor = target;
|
|
71
|
-
for (let index = 0; index < path.length; index++) {
|
|
72
|
-
const key = path[index];
|
|
73
|
-
if (key === void 0) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
const isLast = index === path.length - 1;
|
|
77
|
-
const nextKey = path[index + 1];
|
|
78
|
-
if (isLast) {
|
|
79
|
-
cursor[key] = value;
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
if (cursor[key] === void 0) {
|
|
83
|
-
cursor[key] = typeof nextKey === "number" ? [] : {};
|
|
84
|
-
}
|
|
85
|
-
const nextCursor = cursor[key];
|
|
86
|
-
if (!nextCursor || typeof nextCursor !== "object") {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
cursor = nextCursor;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
51
|
function getObjectShape(schema) {
|
|
93
52
|
const unwrapped = unwrapSchema(schema);
|
|
94
53
|
if (!unwrapped) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
interface ToastLike {
|
|
2
|
+
add: (payload: {
|
|
3
|
+
title?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
color?: 'success' | 'error' | 'warning' | 'info' | 'neutral';
|
|
6
|
+
}) => void;
|
|
7
|
+
}
|
|
8
|
+
interface CopyTextWithToastOptions {
|
|
9
|
+
text: string;
|
|
10
|
+
toast: ToastLike;
|
|
11
|
+
description: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function copyTextWithSuccessToast(options: CopyTextWithToastOptions): Promise<boolean>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export async function copyTextWithSuccessToast(options) {
|
|
2
|
+
if (!options.text) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
await navigator.clipboard.writeText(options.text);
|
|
6
|
+
options.toast.add({
|
|
7
|
+
title: options.title ?? "Copied",
|
|
8
|
+
description: options.description,
|
|
9
|
+
color: "success"
|
|
10
|
+
});
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function toCsvRow(values) {
|
|
2
|
+
return values.map((value) => {
|
|
3
|
+
const escaped = value.replaceAll('"', '""');
|
|
4
|
+
return `"${escaped}"`;
|
|
5
|
+
}).join(",");
|
|
6
|
+
}
|
|
7
|
+
export function toTsvRow(values) {
|
|
8
|
+
return values.map((value) => value.replace(/[\t\r\n]+/g, " ")).join(" ");
|
|
9
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type PathSegment = string | number;
|
|
2
|
+
export declare function parseHeaderPath(header: string): PathSegment[];
|
|
3
|
+
export declare function setDeep(target: Record<string, unknown>, path: PathSegment[], value: unknown): void;
|
|
4
|
+
export declare function flattenRecordToStringMap(record: Record<string, unknown>, prefix?: string): Map<string, string>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const EXCLUDED_EXPORT_PATHS = /* @__PURE__ */ new Set(["__hash__", "body"]);
|
|
2
|
+
export function parseHeaderPath(header) {
|
|
3
|
+
const tokens = [];
|
|
4
|
+
const parts = header.split(".");
|
|
5
|
+
for (const part of parts) {
|
|
6
|
+
const matches = part.matchAll(/([^[]+)|(\[(\d+)\])/g);
|
|
7
|
+
for (const match of matches) {
|
|
8
|
+
if (match[1]) {
|
|
9
|
+
tokens.push(match[1]);
|
|
10
|
+
}
|
|
11
|
+
if (match[3]) {
|
|
12
|
+
tokens.push(Number.parseInt(match[3], 10));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return tokens;
|
|
17
|
+
}
|
|
18
|
+
export function setDeep(target, path, value) {
|
|
19
|
+
if (!path.length) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
let cursor = target;
|
|
23
|
+
for (let index = 0; index < path.length; index++) {
|
|
24
|
+
const key = path[index];
|
|
25
|
+
if (key === void 0) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const isLast = index === path.length - 1;
|
|
29
|
+
const nextKey = path[index + 1];
|
|
30
|
+
if (isLast) {
|
|
31
|
+
cursor[key] = value;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (cursor[key] === void 0) {
|
|
35
|
+
cursor[key] = typeof nextKey === "number" ? [] : {};
|
|
36
|
+
}
|
|
37
|
+
const nextCursor = cursor[key];
|
|
38
|
+
if (!nextCursor || typeof nextCursor !== "object") {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
cursor = nextCursor;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function flattenRecordToStringMap(record, prefix = "") {
|
|
45
|
+
const out = /* @__PURE__ */ new Map();
|
|
46
|
+
for (const [key, value] of Object.entries(record)) {
|
|
47
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
48
|
+
if (EXCLUDED_EXPORT_PATHS.has(path)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (value === null || value === void 0) {
|
|
52
|
+
out.set(path, "");
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
if (value.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item))) {
|
|
57
|
+
out.set(path, value.map((item) => item == null ? "" : String(item)).join(", "));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (let index = 0; index < value.length; index++) {
|
|
61
|
+
const item = value[index];
|
|
62
|
+
const arrayPath = `${path}[${index}]`;
|
|
63
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
64
|
+
flattenRecordToStringMap(item, arrayPath).forEach((nestedValue, nestedKey) => {
|
|
65
|
+
out.set(nestedKey, nestedValue);
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
out.set(arrayPath, item == null ? "" : String(item));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (value instanceof Date) {
|
|
74
|
+
out.set(path, value.toISOString());
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (typeof value === "object") {
|
|
78
|
+
flattenRecordToStringMap(value, path).forEach((nestedValue, nestedKey) => {
|
|
79
|
+
out.set(nestedKey, nestedValue);
|
|
80
|
+
});
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
out.set(path, String(value));
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|