Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
<template>
  <ion-modal :is-open="sessionStore.secretMode" @didDismiss="handleDismiss">
    <ion-header>
      <ion-toolbar>
        <ion-title>Bulk Upload - Tech Handbook</ion-title>
        <ion-buttons slot="end">
          <ion-button @click="handleClose">Close</ion-button>
        </ion-buttons>
      </ion-toolbar>
    </ion-header>
    <ion-content class="ion-padding">
      <div class="bulk-upload-content">
        <h2 class="upload-title">Tech Handbook Upload</h2>
        <p class="upload-description">
          Paste tab-separated data below. Format: <code>task_code[TAB]tech_handbook_instructions</code><br>
          Instructions will be split into steps by sentence (periods followed by two spaces).
        </p>

        <div class="textarea-container">
          <ion-textarea
            v-model="tsvData"
            :rows="12"
            placeholder="task code&#9;tech handbook instructions
PA1E&#9;Snake out or jet main fixture...  Flush out p-traps...  Replace p-traps as needed..."
            class="csv-textarea"
          />
        </div>

        <div class="preview-section" v-if="parsedEntries.length > 0">
          <h3 class="preview-title">Preview ({{ parsedEntries.length }} task codes)</h3>
          <div class="preview-entries">
            <div
              v-for="(entry, index) in parsedEntries.slice(0, 5)"
              :key="index"
              class="preview-entry"
            >
              <div class="entry-header">
                <span class="task-code">{{ entry.taskCode }}</span>
                <span class="step-count">{{ entry.steps.length }} steps</span>
              </div>
              <ol class="entry-steps">
                <li v-for="(step, stepIndex) in entry.steps.slice(0, 3)" :key="stepIndex">
                  {{ step }}
                </li>
                <li v-if="entry.steps.length > 3" class="more-steps">
                  ...and {{ entry.steps.length - 3 }} more steps
                </li>
              </ol>
            </div>
            <p v-if="parsedEntries.length > 5" class="preview-note">
              ...and {{ parsedEntries.length - 5 }} more task codes
            </p>
          </div>
        </div>

        <div class="action-buttons">
          <ion-button fill="outline" @click="clearData">Clear</ion-button>
          <ion-button @click="processData" :disabled="parsedEntries.length === 0">
            Process {{ parsedEntries.length }} Task Codes
          </ion-button>
          <ion-button color="success" @click="uploadToServer" :disabled="parsedEntries.length === 0">
            Upload to Server
          </ion-button>
        </div>
      </div>
    </ion-content>
  </ion-modal>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import {
  IonModal,
  IonHeader,
  IonToolbar,
  IonTitle,
  IonButtons,
  IonButton,
  IonContent,
  IonTextarea,
  toastController,
} from '@ionic/vue';
import { useSessionStore } from '@/stores/session';
import { getSQLite } from '@/dataAccess/getSQLite';
import { getApi } from '@/dataAccess/getApi';
import { ChangesetProcessor, Change } from '@/dataAccess/changeset-processor';

interface TechHandbookEntry {
  taskCode: string;
  steps: string[];
}

// Generate a hash-based refId for contentItems to enable deduplication
function generateContentRefId(content: string): string {
  const hash = content.split('').reduce((a, b) => {
    a = ((a << 5) - a) + b.charCodeAt(0);
    return a & a;
  }, 0).toString(36);
  return `th_${Math.abs(parseInt(hash, 10) || 0).toString(36)}`;
}

const sessionStore = useSessionStore();
const tsvData = ref('');

// Parse TSV data into task code + steps entries
const parsedEntries = computed((): TechHandbookEntry[] => {
  if (!tsvData.value.trim()) return [];

  const lines = tsvData.value.trim().split('\n');
  const entries: TechHandbookEntry[] = [];

  for (const line of lines) {
    // Skip header row if present
    if (line.toLowerCase().startsWith('task code') || line.toLowerCase().startsWith('task_code')) {
      continue;
    }

    // Split by tab
    const parts = line.split('\t');
    if (parts.length < 2) continue;

    const taskCode = parts[0].trim();
    const instructions = parts[1].trim();

    if (!taskCode || !instructions) continue;

    // Parse instructions into steps
    // Split by ".  " (period followed by two spaces) which is the pattern in the data
    const steps = parseInstructionsToSteps(instructions);

    entries.push({ taskCode, steps });
  }

  return entries;
});

// Parse instruction text into array of steps
function parseInstructionsToSteps(text: string): string[] {
  // Split by ".  " (period followed by two spaces)
  // This preserves sentences that end with periods but aren't followed by two spaces
  const rawSteps = text.split(/\.\s{2,}/);

  return rawSteps
    .map(step => step.trim())
    .filter(step => step.length > 0)
    .map(step => {
      // Add period back if the step doesn't end with punctuation
      if (!/[.!?]$/.test(step)) {
        return step + '.';
      }
      return step;
    });
}

function handleDismiss() {
  // Don't auto-close, let user explicitly close
}

async function handleClose() {
  await sessionStore.toggleSecretMode();
}

function clearData() {
  tsvData.value = '';
}

async function processData() {
  if (parsedEntries.value.length === 0) return;

  console.log('[BulkUpload] Processing tech handbook entries:', parsedEntries.value);

  try {
    // Get database connection
    const sql = await getSQLite();
    const processor = new ChangesetProcessor(sql.dbConn);
    const orgId = 'clientApp'; // Standard org ID for local changes

    // Get all existing offer refIds from database
    const existingOffersResult = await sql.dbConn.query(
      `SELECT refId FROM offers WHERE org = 'clientApp'`
    );
    const existingRefIds = new Set(
      existingOffersResult.values?.map((r: any) => r.refId) || []
    );

    // Filter entries to only those with existing offers
    const validEntries = parsedEntries.value.filter(
      entry => existingRefIds.has(entry.taskCode)
    );
    const skippedCount = parsedEntries.value.length - validEntries.length;

    console.log(`[BulkUpload] Found ${validEntries.length} valid entries, skipping ${skippedCount} with missing offers`);

    if (validEntries.length === 0) {
      const toast = await toastController.create({
        message: `No matching offers found for any of the ${parsedEntries.value.length} task codes`,
        duration: 5000,
        color: 'warning',
      });
      await toast.present();
      return;
    }

    // Generate changesets only for valid entries
    const changes = generateChangesets(validEntries);
    console.log('[BulkUpload] Generated changes:', changes);

    // Process changesets (false = don't use internal transaction to avoid nested transaction error)
    await processor.processChangeset(changes, orgId, false);
    await sql.saveDb();

    // Build success message
    let message = `Successfully processed ${validEntries.length} task codes`;
    if (skippedCount > 0) {
      message += ` (skipped ${skippedCount} with no matching offers)`;
    }

    const toast = await toastController.create({
      message,
      duration: 3000,
      color: 'success',
    });
    await toast.present();

    // Clear data after successful processing
    clearData();
  } catch (error) {
    console.error('[BulkUpload] Error processing data:', error);
    const toast = await toastController.create({
      message: `Error: ${(error as Error).message}`,
      duration: 5000,
      color: 'danger',
    });
    await toast.present();
  }
}

async function uploadToServer() {
  if (parsedEntries.value.length === 0) return;

  console.log('[BulkUpload] Uploading tech handbook entries to server:', parsedEntries.value);

  try {
    const { pb } = await getApi();

    // Find the book named "TNFR Legacy Tiers"
    const books = await pb.collection('books').getFullList({
      filter: `name = "TNFR Legacy Tiers"`,
    });

    if (books.length === 0) {
      const toast = await toastController.create({
        message: 'Book "TNFR Legacy Tiers" not found',
        duration: 5000,
        color: 'danger',
      });
      await toast.present();
      return;
    }

    const book = books[0];
    const activeOrg = pb.authStore.record?.expand?.activeOrg?.id;

    if (!activeOrg) {
      const toast = await toastController.create({
        message: 'No active organization found',
        duration: 5000,
        color: 'danger',
      });
      await toast.present();
      return;
    }

    // Generate changesets for all entries (no filtering - upload all)
    const changes = generateChangesets(parsedEntries.value);
    console.log('[BulkUpload] Generated changes for upload:', changes);

    // Split into batches to avoid JSON size limit (max ~1MB)
    const BATCH_SIZE = 500;
    const batches: Change[][] = [];
    for (let i = 0; i < changes.length; i += BATCH_SIZE) {
      batches.push(changes.slice(i, i + BATCH_SIZE));
    }

    const timestamp = Date.now();
    for (let i = 0; i < batches.length; i++) {
      const batch = batches[i];
      const randomSuffix = Math.random().toString(36).substring(2, 8);
      await pb.collection('pricebookChanges').create({
        org: activeOrg,
        book: book.id,
        refId: `tnfr-th-${timestamp}-${i + 1}-${randomSuffix}`,
        changeset: batch,
      });
    }

    const toast = await toastController.create({
      message: `Successfully uploaded ${parsedEntries.value.length} task codes (${changes.length} changes in ${batches.length} batch${batches.length > 1 ? 'es' : ''}) to server`,
      duration: 3000,
      color: 'success',
    });
    await toast.present();

    // Clear data after successful upload
    clearData();
  } catch (error) {
    console.error('[BulkUpload] Error uploading to server:', error);
    const toast = await toastController.create({
      message: `Error: ${(error as Error).message}`,
      duration: 5000,
      color: 'danger',
    });
    await toast.present();
  }
}

// Generate changesets for tech handbook entries
function generateChangesets(entries: TechHandbookEntry[]): Change[] {
  const changes: Change[] = [];
  const contentItemRefIds = new Map<string, string>(); // content -> refId mapping
  const seenRefIds = new Set<string>(); // track refIds we've already added to changes

  // First pass: create contentItems for all unique steps
  for (const entry of entries) {
    for (const step of entry.steps) {
      if (!contentItemRefIds.has(step)) {
        const refId = generateContentRefId(step);
        contentItemRefIds.set(step, refId);

        // Only create if we haven't already added this refId in this batch
        if (!seenRefIds.has(refId)) {
          seenRefIds.add(refId);
          changes.push({
            collection: 'contentItems',
            operation: 'create',
            data: {
              name: step.substring(0, 50) + (step.length > 50 ? '...' : ''),
              content: step,
              refId: refId,
            },
          });
        }
      }
    }
  }

  // Second pass: update offers with techHandbook refs
  for (const entry of entries) {
    const stepRefIds = entry.steps.map(step => contentItemRefIds.get(step)!);

    changes.push({
      collection: 'offers',
      operation: 'update',
      data: {
        refId: entry.taskCode, // Task code IS the offer refId
        refs: [{
          collection: 'contentItems',
          refIds: stepRefIds,
          targetField: 'techHandbook',
        }],
      },
    });
  }

  return changes;
}

// Clear data when modal closes
watch(() => sessionStore.secretMode, (isOpen) => {
  if (!isOpen) {
    tsvData.value = '';
  }
});
</script>

<style scoped>
.bulk-upload-content {
  max-width: 800px;
  margin: 0 auto;
}

.upload-title {
  margin: 0 0 8px 0;
  font-size: 24px;
  font-weight: 600;
  color: var(--ion-text-color);
}

.upload-description {
  margin: 0 0 24px 0;
  font-size: 16px;
  color: var(--ion-color-medium);
  line-height: 1.6;
}

.upload-description code {
  background: rgba(255, 255, 255, 0.1);
  padding: 2px 6px;
  border-radius: 4px;
  font-family: monospace;
  font-size: 14px;
}

.textarea-container {
  margin-bottom: 24px;
}

.csv-textarea {
  --background: rgba(255, 255, 255, 0.1);
  --color: var(--ion-text-color);
  --padding-start: 12px;
  --padding-end: 12px;
  --padding-top: 12px;
  --padding-bottom: 12px;
  border: 1px solid var(--ion-color-medium);
  border-radius: 8px;
  font-family: monospace;
  font-size: 14px;
  line-height: 1.5;
}

.preview-section {
  margin-bottom: 24px;
}

.preview-title {
  margin: 0 0 12px 0;
  font-size: 18px;
  font-weight: 600;
  color: var(--ion-text-color);
}

.preview-entries {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.preview-entry {
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--ion-color-medium);
  border-radius: 8px;
  padding: 12px 16px;
}

.entry-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.task-code {
  font-family: monospace;
  font-size: 16px;
  font-weight: 700;
  color: var(--ion-color-primary);
  background: rgba(var(--ion-color-primary-rgb), 0.1);
  padding: 4px 8px;
  border-radius: 4px;
}

.step-count {
  font-size: 14px;
  color: var(--ion-color-medium);
}

.entry-steps {
  margin: 0;
  padding-left: 20px;
  font-size: 14px;
  line-height: 1.6;
  color: var(--ion-text-color);
}

.entry-steps li {
  margin-bottom: 4px;
}

.entry-steps .more-steps {
  list-style: none;
  margin-left: -20px;
  font-style: italic;
  color: var(--ion-color-medium);
}

.preview-note {
  margin: 8px 0 0 0;
  padding: 8px 12px;
  font-size: 14px;
  font-style: italic;
  color: var(--ion-color-medium);
}

.action-buttons {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}
</style>