Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 202 additions & 7 deletions src/cli/SchemaInspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
FieldDefinition,
} from '../types';

/**
* Result of locale detection from date values.
* @internal
*/
interface LocaleDetectionResult {
/** Detected locale (BCP 47) or undefined if detection not possible */
locale?: string;
/** Whether the detection was ambiguous (all date parts <= 12) */
ambiguous: boolean;
}

/**
* Inspects AppSheet tables and generates schema definitions.
*
Expand All @@ -35,6 +46,27 @@
* ```
*/
export class SchemaInspector {
/** Locale mapping: partOrder + separator → representative locale */
private static readonly LOCALE_MAP: Record<string, string> = {
'day,month,year:.': 'de-DE',
'day,month,year:/': 'en-GB',
'month,day,year:/': 'en-US',
'year,month,day:/': 'ja-JP',
};

/** Default locales for ambiguous cases, keyed by separator */
private static readonly DEFAULT_LOCALE: Record<string, string> = {
'/': 'en-US',
'.': 'de-DE',
};

/** Pattern matching locale-formatted dates: DD.MM.YYYY, MM/DD/YYYY, YYYY/MM/DD etc. */
private static readonly LOCALE_DATE_PATTERN = /^\d{1,4}[./]\d{1,2}[./]\d{1,4}$/;

/** Pattern matching locale-formatted datetimes: date part + space + time part */
private static readonly LOCALE_DATETIME_PATTERN =
/^\d{1,4}[./]\d{1,2}[./]\d{1,4}\s+\d{1,2}:\d{2}/;

constructor(private client: AppSheetClient) {}

/**
Expand Down Expand Up @@ -84,7 +116,9 @@

for (const fieldName of fieldNames) {
// Collect all values for this field
const values = sampleRows.map((row) => row[fieldName]).filter((v) => v !== null && v !== undefined);
const values = sampleRows
.map((row) => row[fieldName])
.filter((v) => v !== null && v !== undefined);

if (values.length === 0) {
// No non-null values found
Expand Down Expand Up @@ -117,12 +151,22 @@
fields[fieldName] = fieldDef;
}

// Detect locale from date field values
const { locale, ambiguous } = this.detectLocale(sampleRows, fields);

let warning: string | undefined;
if (ambiguous && locale) {
warning = `Locale detection ambiguous, defaulting to "${locale}". Please verify.`;
}

return {
tableName,
keyField: this.guessKeyField(sampleRows[0]),
fields,
locale,
warning,
};
} catch (error: any) {

Check warning on line 169 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 22.x

Unexpected any. Specify a different type

Check warning on line 169 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 20.x

Unexpected any. Specify a different type

Check warning on line 169 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 18.x

Unexpected any. Specify a different type
throw new Error(`Failed to inspect table "${tableName}": ${error.message}`);
}
}
Expand All @@ -130,7 +174,7 @@
/**
* Infer AppSheet field type from value with improved heuristics
*/
private inferType(value: any): AppSheetFieldType {

Check warning on line 177 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 22.x

Unexpected any. Specify a different type

Check warning on line 177 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 20.x

Unexpected any. Specify a different type

Check warning on line 177 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 18.x

Unexpected any. Specify a different type
if (value === null || value === undefined) {
return 'Text'; // Default
}
Expand Down Expand Up @@ -169,6 +213,16 @@
return 'Date';
}

// Locale-formatted DateTime (e.g. "25.03.2026 14:30:00", "03/25/2026 2:30 PM")
if (SchemaInspector.LOCALE_DATETIME_PATTERN.test(value)) {
return 'DateTime';
}

// Locale-formatted Date (e.g. "25.03.2026", "03/25/2026", "2026/03/25")
if (SchemaInspector.LOCALE_DATE_PATTERN.test(value)) {
return 'Date';
}

// Email pattern
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Email';
Expand Down Expand Up @@ -199,7 +253,7 @@
* Heuristic: If there are relatively few unique values compared to total values,
* it's likely an enum field (e.g., status, category, priority).
*/
private looksLikeEnum(values: any[]): boolean {

Check warning on line 256 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 22.x

Unexpected any. Specify a different type

Check warning on line 256 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 20.x

Unexpected any. Specify a different type

Check warning on line 256 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 18.x

Unexpected any. Specify a different type
// Only consider string values
const stringValues = values.filter((v) => typeof v === 'string');
if (stringValues.length === 0) {
Expand Down Expand Up @@ -228,7 +282,7 @@
/**
* Extract allowed values for Enum/EnumList fields
*/
private extractAllowedValues(values: any[], fieldType: AppSheetFieldType): string[] {

Check warning on line 285 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 22.x

Unexpected any. Specify a different type

Check warning on line 285 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 20.x

Unexpected any. Specify a different type

Check warning on line 285 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 18.x

Unexpected any. Specify a different type
const uniqueValues = new Set<string>();

for (const value of values) {
Expand All @@ -248,10 +302,110 @@
return Array.from(uniqueValues).sort();
}

/**
* Detect locale from date values in sample rows.
*
* Analyzes Date/DateTime/ChangeTimestamp field values to determine the locale
* of the AppSheet app by examining separator and part order.
*
* @param rows - Sample rows from AppSheet API
* @param fields - Already-inferred field definitions
* @returns Detected locale or undefined if detection not possible
*/
private detectLocale(
rows: Record<string, any>[],

Check warning on line 316 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 22.x

Unexpected any. Specify a different type

Check warning on line 316 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 20.x

Unexpected any. Specify a different type

Check warning on line 316 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 18.x

Unexpected any. Specify a different type
fields: Record<string, FieldDefinition>
): LocaleDetectionResult {
// 1. Collect date field names
const dateFieldNames = Object.entries(fields)
.filter(([, def]) => ['Date', 'DateTime', 'ChangeTimestamp'].includes(def.type))
.map(([name]) => name);

if (dateFieldNames.length === 0) {
return { locale: undefined, ambiguous: false };
}

// 2. Collect non-ISO date strings (extract date part from DateTime values)
const dateStrings: string[] = [];
for (const row of rows) {
for (const fieldName of dateFieldNames) {
const value = row[fieldName];
if (typeof value !== 'string') continue;
if (/^\d{4}-\d{2}-\d{2}/.test(value)) continue; // Skip ISO

// For DateTime, extract date part only (before space)
const spaceIdx = value.indexOf(' ');
const datePart = spaceIdx > 0 ? value.substring(0, spaceIdx) : value;
dateStrings.push(datePart);
}
}

if (dateStrings.length === 0) {
return { locale: undefined, ambiguous: false };
}

// 3. Detect separator from first value
const separator = dateStrings[0].match(/[^0-9]/)?.[0];
if (!separator) {
return { locale: undefined, ambiguous: false };
}

// 4. Determine part order
const parts = dateStrings[0].split(separator);
if (parts.length !== 3) {
return { locale: undefined, ambiguous: false };
}

// Year position (4-digit part)
const yearPos = parts.findIndex((p) => p.length === 4);
if (yearPos === 0) {
// YMD (e.g. ja-JP: "2026/03/12")
const key = `year,month,day:${separator}`;
const locale = SchemaInspector.LOCALE_MAP[key];
return { locale, ambiguous: false };
}

if (yearPos !== 2) {
return { locale: undefined, ambiguous: false };
}

// Year at position 2 → DMY or MDY
// Check all date values for disambiguation
let foundFirstPartGt12 = false;
let foundSecondPartGt12 = false;

for (const dateStr of dateStrings) {
const p = dateStr.split(separator);
if (p.length !== 3) continue;
const first = parseInt(p[0], 10);
const second = parseInt(p[1], 10);
if (first > 12) foundFirstPartGt12 = true;
if (second > 12) foundSecondPartGt12 = true;
}

if (foundFirstPartGt12 && !foundSecondPartGt12) {
// First part is day → DMY
const key = `day,month,year:${separator}`;
const locale = SchemaInspector.LOCALE_MAP[key];
return { locale, ambiguous: false };
}

if (foundSecondPartGt12 && !foundFirstPartGt12) {
// Second part is day → MDY
const key = `month,day,year:${separator}`;
const locale = SchemaInspector.LOCALE_MAP[key];
return { locale, ambiguous: false };
}

// Ambiguous: no part > 12, or both > 12 (shouldn't happen with valid dates)
const defaultLocale = SchemaInspector.DEFAULT_LOCALE[separator] || 'en-US';
return { locale: defaultLocale, ambiguous: true };
}

/**
* Guess the key field from row data
*/
private guessKeyField(row: Record<string, any>): string {

Check warning on line 408 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 22.x

Unexpected any. Specify a different type

Check warning on line 408 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 20.x

Unexpected any. Specify a different type

Check warning on line 408 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 18.x

Unexpected any. Specify a different type
// Common key field names
const commonKeys = ['id', 'key', 'ID', 'Key', '_RowNumber', 'Id'];

Expand All @@ -273,27 +427,66 @@
tableNames: string[]
): Promise<ConnectionDefinition> {
const tables: Record<string, TableDefinition> = {};
const detectedLocales: string[] = [];

for (const tableName of tableNames) {
console.log(`Inspecting table: ${tableName}...`);
const inspection = await this.inspectTable(tableName);

tables[this.toSchemaName(tableName)] = {
const tableDef: TableDefinition = {
tableName: inspection.tableName,
keyField: inspection.keyField,
fields: inspection.fields,
};

// Set locale on table level if detected
if (inspection.locale) {
tableDef.locale = inspection.locale;
detectedLocales.push(inspection.locale);
}

tables[this.toSchemaName(tableName)] = tableDef;

if (inspection.warning) {
console.warn(` Warning: ${inspection.warning}`);
}
}

return {
// Connection-level locale = most frequent detected locale
const connectionLocale = this.mostFrequent(detectedLocales);

const connectionDef: ConnectionDefinition = {
appId: '${APPSHEET_APP_ID}', // Placeholder
applicationAccessKey: '${APPSHEET_ACCESS_KEY}', // Placeholder
tables,
};

if (connectionLocale) {
connectionDef.locale = connectionLocale;
}

return connectionDef;
}

/**
* Returns the most frequent string in an array, or undefined if empty.
* @internal
*/
private mostFrequent(values: string[]): string | undefined {
if (values.length === 0) return undefined;
const counts = new Map<string, number>();
for (const v of values) {
counts.set(v, (counts.get(v) || 0) + 1);
}
let best = values[0];
let bestCount = 0;
for (const [v, c] of counts) {
if (c > bestCount) {
best = v;
bestCount = c;
}
}
return best;
}

/**
Expand All @@ -303,10 +496,12 @@
* - "worklog" -> "worklogs"
*/
toSchemaName(tableName: string): string {
return tableName
.replace(/^extract_/, '')
.replace(/_/g, '')
.toLowerCase() + 's';
return (
tableName
.replace(/^extract_/, '')
.replace(/_/g, '')
.toLowerCase() + 's'
);
}

/**
Expand All @@ -324,7 +519,7 @@
});

if (result.rows && result.rows.length > 0) {
return result.rows.map((row: any) => row.tableName || row.name);

Check warning on line 522 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 22.x

Unexpected any. Specify a different type

Check warning on line 522 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 20.x

Unexpected any. Specify a different type

Check warning on line 522 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 18.x

Unexpected any. Specify a different type
}
} catch {
// Continue to next strategy
Expand All @@ -335,7 +530,7 @@
await this.client.find({
tableName: '_nonexistent_table_xyz_123',
});
} catch (error: any) {

Check warning on line 533 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 22.x

Unexpected any. Specify a different type

Check warning on line 533 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 20.x

Unexpected any. Specify a different type

Check warning on line 533 in src/cli/SchemaInspector.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 18.x

Unexpected any. Specify a different type
const errorMsg = error.message || error.toString();

// Look for table list in error message
Expand Down
31 changes: 25 additions & 6 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,15 @@
import { AppSheetClient } from '../client';
import { SchemaInspector } from './SchemaInspector';
import { SchemaLoader } from '../utils';
import { SchemaConfig, ConnectionDefinition } from '../types';
import { SchemaConfig, ConnectionDefinition, TableDefinition } from '../types';

/**
* Create CLI program with all commands
*/
export function createCLI(): Command {
const program = new Command();

program
.name('appsheet')
.description('AppSheet Schema Management CLI')
.version('0.1.0');
program.name('appsheet').description('AppSheet Schema Management CLI').version('0.1.0');

// Command: init
program
Expand Down Expand Up @@ -108,11 +105,19 @@
fs.writeFileSync(options.output, output, 'utf-8');
console.log(`\n✓ Schema generated: ${options.output}`);
console.log('✓ Inspected tables:', tableNames.join(', '));
if (connection.locale) {
console.log(`✓ Locale detected: ${connection.locale}`);
}
console.log('\nPlease review and update:');
console.log(' - Key fields may need manual adjustment');
console.log(' - Field types are inferred and may need refinement');
console.log(' - Add required, enum, and description properties as needed');
if (!connection.locale) {
console.log(
' - No locale detected. Consider adding locale manually for date validation.'
);
}
} catch (error: any) {

Check warning on line 120 in src/cli/commands.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 22.x

Unexpected any. Specify a different type

Check warning on line 120 in src/cli/commands.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 20.x

Unexpected any. Specify a different type

Check warning on line 120 in src/cli/commands.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 18.x

Unexpected any. Specify a different type
console.error('Error:', error.message);
process.exit(1);
}
Expand All @@ -136,7 +141,7 @@
validation.errors.forEach((err) => console.error(` - ${err}`));
process.exit(1);
}
} catch (error: any) {

Check warning on line 144 in src/cli/commands.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 22.x

Unexpected any. Specify a different type

Check warning on line 144 in src/cli/commands.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 20.x

Unexpected any. Specify a different type

Check warning on line 144 in src/cli/commands.ts

View workflow job for this annotation

GitHub Actions / Test on Node.js 18.x

Unexpected any. Specify a different type
console.error('Error:', error.message);
process.exit(1);
}
Expand Down Expand Up @@ -170,16 +175,30 @@

// Add to schema
const schemaName = inspector.toSchemaName(tableName);
schema.connections[connection].tables[schemaName] = {
const tableDef: TableDefinition = {
tableName: inspection.tableName,
keyField: inspection.keyField,
fields: inspection.fields,
};

// Propagate auto-detected locale
if (inspection.locale) {
tableDef.locale = inspection.locale;
}

schema.connections[connection].tables[schemaName] = tableDef;

if (inspection.warning) {
console.warn(` Warning: ${inspection.warning}`);
}

// Write back
const output = yaml.stringify(schema);
fs.writeFileSync(options.schema, output, 'utf-8');
console.log(`✓ Table "${tableName}" added to connection "${connection}"`);
if (inspection.locale) {
console.log(`✓ Locale detected: ${inspection.locale}`);
}
} catch (error: any) {
console.error('Error:', error.message);
process.exit(1);
Expand Down
12 changes: 12 additions & 0 deletions src/types/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,18 @@ export interface TableInspectionResult {
/** Discovered fields with AppSheet types */
fields: Record<string, FieldDefinition>;

/**
* Auto-detected locale from date values in sample data (BCP 47 language tag).
*
* Determined by analyzing Date/DateTime/ChangeTimestamp field values
* for separator and part order patterns. When detection is ambiguous
* (all date parts <= 12), a default locale is assigned based on separator.
*
* @example 'de-DE' // detected from DD.MM.YYYY pattern
* @example 'en-US' // detected from MM/DD/YYYY pattern
*/
locale?: string;

/** Optional warning message */
warning?: string;
}
Expand Down
Loading
Loading