Hello from MCP server
<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	tech handbook instructions
PA1E	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>