Skip to content

Replace Fiddler with native GAS + RichText for hyperlinks #185

@TobyHFerguson

Description

@TobyHFerguson

Motivation

The current architecture uses Fiddler (bmPreFiddler) for spreadsheet I/O and stores hyperlinks as HYPERLINK formulas. This creates significant complexity:

  • HyperlinkUtils.js (~100+ lines) for formula parsing
  • Formula storage in PropertiesService after each save (~50 lines)
  • Formula overlay logic on data load
  • String manipulation for =HYPERLINK("url", "text") construction

Key Insight: RichText objects provide native hyperlink support in GAS, making formula complexity unnecessary. However, Fiddler doesn't support RichText, so replacing formulas with RichText requires removing Fiddler.

Net Benefit: Remove ~100-150 lines of code + external dependency

Current Implementation

With Fiddler + Formulas:

// Reading (requires formula overlay from PropertiesService)
const formula = row.Route; // "=HYPERLINK(\"url\", \"text\")"
const { url, text } = HyperlinkUtils.parseHyperlinkFormula(formula);

// Writing (requires formula construction + storage)
row.Route = `=HYPERLINK("${url}", "${text}")`;
// Later: _storeFormulas() saves to PropertiesService

Proposed Implementation

With Native GAS + RichText:

// Reading
const richText = range.getRichTextValue();
const url = richText.getLinkUrl();
const text = richText.getText();

// Writing
const richText = SpreadsheetApp.newRichTextValue()
    .setText(text)
    .setLinkUrl(url)
    .build();
range.setRichTextValue(richText);

Implementation Plan

Phase 1: Add Native GAS Conversion Logic

File: src/ScheduleAdapter.js

Replace Fiddler calls with native GAS:

_loadData() {
    const sheet = this._getSheet();
    const lastRow = sheet.getLastRow();
    const lastCol = sheet.getLastColumn();
    
    // Get headers
    const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
    
    // Get values
    const values = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();
    
    // Get RichText for Route and Ride columns
    const routeColIndex = headers.indexOf('Route');
    const rideColIndex = headers.indexOf('Ride');
    const richTextValues = sheet.getRange(2, 1, lastRow - 1, lastCol).getRichTextValues();
    
    // Convert to objects
    this._data = values.map((row, i) => {
        const obj = { _rowNum: i + 2 };
        headers.forEach((header, j) => {
            if (j === routeColIndex || j === rideColIndex) {
                // Extract URL from RichText
                const richText = richTextValues[i][j];
                obj[header] = richText ? richText.getLinkUrl() || row[j] : row[j];
            } else {
                obj[header] = row[j];
            }
        });
        return obj;
    });
}

Phase 2: Update Save Logic for RichText

File: src/ScheduleAdapter.js

Replace formula preservation with RichText writes:

save() {
    const sheet = this._getSheet();
    const dirtyRows = this._data.filter(row => row._dirtyFields?.size > 0);
    
    dirtyRows.forEach(row => {
        row._dirtyFields.forEach(fieldName => {
            const col = this._headers.indexOf(fieldName) + 1;
            const cell = sheet.getRange(row._rowNum, col);
            
            // Special handling for Route/Ride columns
            if (fieldName === 'Route' || fieldName === 'Ride') {
                const { text, url } = this._parseRouteOrRide(row[fieldName]);
                const richText = SpreadsheetApp.newRichTextValue()
                    .setText(text)
                    .setLinkUrl(url)
                    .build();
                cell.setRichTextValue(richText);
            } else if (typeof row[fieldName] === 'string' && row[fieldName].startsWith('=')) {
                cell.setFormula(row[fieldName]);
            } else {
                cell.setValue(row[fieldName]);
            }
        });
        row._dirtyFields.clear();
    });
    
    SpreadsheetApp.flush();
}

Phase 3: Remove Formula Complexity

Files to Delete/Modify:

  • ✅ Delete: src/HyperlinkUtils.js (entire file, ~100+ lines)
  • ✅ Delete: test/__tests__/HyperlinkUtils.test.js
  • ✅ Remove from src/ScheduleAdapter.js:
    • _storeFormulas() method
    • _overlayFormulas() method
    • PropertiesService formula storage logic
  • ✅ Remove from src/Exports.js: HyperlinkUtils export
  • ✅ Remove from src/gas-globals.d.ts: HyperlinkUtils declaration
  • ✅ Update package.json: Remove bmPreFiddler dependency

Phase 4: Update Row Class

File: src/RowCore.js

Simplify Route/Ride accessors (no formula parsing needed):

get RouteURL() {
    // Before: return HyperlinkUtils.parseHyperlinkFormula(this._data.Route).url;
    // After: return this._data.Route; // Already the URL from RichText
}

setRouteLink(text, url) {
    // Before: this._data.Route = `=HYPERLINK("${url}", "${text}")`;
    // After: Store both for ScheduleAdapter to create RichText
    this._data.Route = { text, url };
    this._markDirty('Route');
}

Testing Requirements

Unit Tests

  • ✅ Native GAS array ↔ object conversion
  • ✅ RichText URL extraction
  • ✅ RichText creation and writing
  • ✅ Row.RouteURL getter returns correct URL
  • ✅ Row.setRouteLink marks field dirty

Integration Tests

Create test/__tests__/ScheduleAdapter.richtext.test.js:

  • Load data with RichText hyperlinks
  • Extract URLs correctly
  • Save RichText hyperlinks
  • Verify spreadsheet contains correct RichText

Manual Testing in GAS

  1. Deploy to dev environment
  2. Open spreadsheet with existing HYPERLINK formulas
  3. Run menu command that reads Route/Ride URLs
  4. Verify URLs extracted correctly
  5. Run menu command that writes Route/Ride links
  6. Verify spreadsheet shows RichText hyperlinks (not formulas)
  7. Click links to verify they work

Benefits

Remove ~100-150 lines of code
Remove external dependency (Fiddler/bmPreFiddler)
Simpler mental model (RichText vs formula strings)
Native GAS feature (better long-term support)
No PropertiesService storage (one less state management concern)
Better performance (no formula parsing overhead)

Migration Notes

Backward Compatibility:

  • Existing spreadsheets with HYPERLINK formulas will need one-time conversion
  • Create migration script: scripts/migrate-formulas-to-richtext.js
  • Migration reads formulas, converts to RichText, writes back

Rollout Strategy:

  1. Deploy code with both formula + RichText support
  2. Run migration script on production spreadsheet
  3. Verify all links work
  4. Remove formula support code

Related Issues

  • Addresses complexity mentioned in ScheduleAdapter architecture discussions
  • Enables further simplification of Row abstraction
  • Reduces technical debt from formula preservation logic

Metadata

Metadata

Labels

enhancementNew feature or requestrefactoringCode refactoring for maintainability

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions