Skip to content

Commit

Permalink
god knows
Browse files Browse the repository at this point in the history
  • Loading branch information
finn-wa committed Feb 6, 2023
1 parent d3861a3 commit 6cb7b0d
Show file tree
Hide file tree
Showing 9 changed files with 312 additions and 61 deletions.
4 changes: 3 additions & 1 deletion bin/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function getBuildOptions(args) {
bundle: true,
format: "cjs",
logLevel: "info",
minify: args.target === "prod",
minify: args.minify,
outdir: "build/out",
platform: "node",
plugins: [],
Expand Down Expand Up @@ -53,6 +53,7 @@ async function runEsbuild(options) {
* @property {boolean} watch rebuild on file change
* @property {boolean} sourcemap produce sourcemaps for debugging
* @property {boolean} analyse print bundle analysis
* @property {boolean} minify minify code
*/
program
.addOption(
Expand All @@ -63,6 +64,7 @@ program
.option("-w, --watch", "rebuild on file changes", false)
.option("-s, --sourcemap", "produce sourcemaps for debugging", false)
.option("-a, --analyse", "print bundle analysis", false)
.option("-m, --minify", "minify code", false)
.parse();

const buildOptions = getBuildOptions(program.opts());
Expand Down
Empty file.
119 changes: 91 additions & 28 deletions src/app/import/grocy-importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,50 @@ import {
CreatedProductResponse,
GrocyProductService,
} from "@gt/grocy/products/grocy-product-service";
import { StockAddRequest } from "@gt/grocy/stock/types";
import { PromptProvider } from "@gt/prompts/prompt-provider";
import { NewProductPayloads } from "@gt/store/foodstuffs/grocy/import/product-converter";
import { prettyPrint } from "@gt/utils/logger";
import { Logger } from "ajv";
import { ImportOptions } from "./options";
import { Logger, prettyPrint } from "@gt/utils/logger";
import { Store } from "@gt/utils/store";
import { BehaviorSubject } from "rxjs";
import { ImportListOptions, ImportOptions } from "./options";

export interface BaseProductImport {
readonly payloads: NewProductPayloads;
}
export interface QueuedProductImport extends BaseProductImport {
readonly status: "queued";
}
export interface SuccessfulProductImport extends BaseProductImport {
readonly status: "success";

export type Status = "queued" | "success" | "error";
export type WithStatus<T extends Status> = { readonly status: T };

export interface QueuedProductImport extends BaseProductImport, WithStatus<"queued"> {}

export interface SuccessfulProductImport extends BaseProductImport, WithStatus<"success"> {
readonly response: CreatedProductResponse;
}
export interface ErroredProductImport extends BaseProductImport {
readonly status: "error";
export interface ErroredProductImport extends BaseProductImport, WithStatus<"error"> {
readonly error: unknown;
}
export type ProductImport = QueuedProductImport | SuccessfulProductImport | ErroredProductImport;

export class ProductImportHelper {
private _state: ProductImport;
export type ProductImportStatus =
| QueuedProductImport
| SuccessfulProductImport
| ErroredProductImport;

export interface ProductToStock {
id: string;
request: StockAddRequest;
}

export type ProductStockStatus<T extends Status = Status> = WithStatus<T> & ProductToStock;

export class ProductImportOperation {
private _state: ProductImportStatus;

constructor(payloads: NewProductPayloads) {
this._state = { status: "queued", payloads };
}

get state(): ProductImport {
get state(): ProductImportStatus {
return { ...this.state };
}

Expand All @@ -56,47 +70,96 @@ export class ProductImportHelper {
}

export interface ImportProductsResult {
state: ProductImport[];
imports: ProductImportStatus[];
stock: ProductStockStatus[];
error?: Error;
}

export interface GrocyImporter {
export interface GrocyProductImporter {
importProducts(options: ImportOptions): Promise<ImportProductsResult>;
// stockProducts(products: ImportProductsResult): Promise<ProductStockStatus<Status>[]>;
}

export abstract class AbstractGrocyImporter implements GrocyImporter {
type ImporterStore = {
imports: ProductImportOperation[];
stock: ProductStockStatus[];
}

export abstract class AbstractGrocyProductImporter implements GrocyProductImporter {
readonly store = new Store<ImporterStore>({imports: [], stock: []});

protected abstract readonly logger: Logger;

constructor(
private readonly grocyProductService: GrocyProductService,
private readonly prompt: PromptProvider
protected readonly grocyProductService: GrocyProductService,
protected readonly prompt: PromptProvider
) {}

protected abstract getProductsToImport(
options: ImportOptions
): Promise<NewProductPayloads[]> | NewProductPayloads[];
): NewProductPayloads[] | Promise<NewProductPayloads[]>;

protected abstract convertToStockRequest(
importResult: ImportProductsResult
): ProductToStock[] | Promise<ProductToStock[]>;

async importProducts(options: ImportOptions): Promise<ImportProductsResult> {
const productsToImport = await this.getProductsToImport(options);
const importHelpers = productsToImport.map((payloads) => new ProductImportHelper(payloads));
for (const productImport of importHelpers) {

this.store.updateIdState('imports', (imports) => imports.concat(
productsToImport.map((payloads) => new ProductImportOperation(payloads))
)
);
let ignoreErrors = false;
const importState = await this.store.selectId('imports');
for (let i = 0; i < importState.length; i++) {
const productImport = importState[i];
try {
const createdProduct = await this.grocyProductService.createProduct(
productImport.payloads.product,
productImport.payloads.quConversions
);
productImport.setSuccessState(createdProduct);
this.store.setIdState('imports', importState);
} catch (error) {
productImport.setErrorState(error);
this.store.setIdState('imports', importState);
this.logger.error(prettyPrint(error));
const continueImport = await this.prompt.confirm(
`Error importing product ${productImport.payloads.product.name}! Continue importing remaining products?`
);
if (!continueImport) break;
if (!ignoreErrors) {
const continueImport = await this.prompt.select(
`Error importing product ${productImport.payloads.product.name}! Select action:`,
[
{ title: "Ignore error", value: "ignore" as const },
{ title: "Ignore all errors for this import", value: "ignoreAll" as const },
],
{ includeExitOption: true }
);
if (!continueImport) {
break;
}
if (continueImport === "ignoreAll") {
ignoreErrors = true;
}
}
}
}
// TODO: print summary
// TODO: print nice summary
const state = importOperations.map((helper) => helper.state);
this.logger.debug(prettyPrint(state));
// TODO: support stocking imported products
return { state: importHelpers.map((helper) => helper.state) };
const stockImportedProducts = await this.prompt.confirm(
"Stock successfully imported products?"
);
if (stockImportedProducts) {
products.
const productsToStock = await this.convertToStockRequest({ imports: state });
const stockState = await this.stockProducts(productsToStock);
return { imports: state, stock: stockState };
}
return { imports: state };
}

protected async stockProducts(products: ProductToStock[]): Promise<ProductStockStatus[]> {}

async importAndStockProducts(options: ImportOptions) {}
}
9 changes: 9 additions & 0 deletions src/grocy/products/grocy-parent-product-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AppTokens } from "@gt/app/di";
import { PromptProvider } from "@gt/prompts/prompt-provider";
import { Logger } from "@gt/utils/logger";
import { from, Observable } from "rxjs";
import { inject, Lifecycle, scoped } from "tsyringe";
import { GrocyProductGroup } from "../grocy-config";
import { GrocyProductGroupIdLookupService } from "../id-lookup/grocy-product-group-id-lookup-service";
Expand Down Expand Up @@ -71,4 +72,12 @@ export class GrocyParentProductService extends GrocyRestService {
})),
]);
}

promptForMatchingParent$(
name: string,
category: GrocyProductGroup,
parents: ParentProduct[]
): Observable<ParentProduct | null> {
return from(this.promptForMatchingParent(name, category, parents));
}
}
89 changes: 58 additions & 31 deletions src/store/foodstuffs/grocy/import/list-importer.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,45 @@
import { AppTokens } from "@gt/app/di";
import {
AbstractGrocyProductImporter,
ImportProductsResult,
ProductToStock,
} from "@gt/app/import/grocy-importer";
import { ImportListOptions, ImportOptions } from "@gt/app/import/options";
import { GrocyParentProductService } from "@gt/grocy/products/grocy-parent-product-service";
import { GrocyProductService } from "@gt/grocy/products/grocy-product-service";
import { Product } from "@gt/grocy/products/types/Product";
import { GrocyStockService } from "@gt/grocy/stock/grocy-stock-service";
import { PromptProvider } from "@gt/prompts/prompt-provider";
import { Logger } from "@gt/utils/logger";
import { RequestError } from "@gt/utils/rest";
import { firstValueFrom, from, iif, switchMap, toArray } from "rxjs";
import { inject, Lifecycle, scoped } from "tsyringe";
import { FoodstuffsListService } from "../../lists/foodstuffs-list-service";
import { List } from "../../lists/foodstuffs-list.model";
import { FoodstuffsListProduct } from "../../models";
import { FoodstuffsToGrocyConverter } from "./product-converter";
import { FoodstuffsToGrocyConverter, NewProductPayloads } from "./product-converter";

@scoped(Lifecycle.ContainerScoped)
export class FoodstuffsListImporter {
private readonly logger = new Logger(this.constructor.name);
export class FoodstuffsListImporter extends AbstractGrocyProductImporter {
protected readonly logger = new Logger(this.constructor.name);

constructor(
private readonly converter: FoodstuffsToGrocyConverter,
private readonly listService: FoodstuffsListService,
private readonly grocyProductService: GrocyProductService,
private readonly grocyParentProductService: GrocyParentProductService,
private readonly grocyStockService: GrocyStockService,
@inject("PromptProvider") private readonly prompt: PromptProvider
) {}
grocyProductService: GrocyProductService,
@inject("PromptProvider") prompt: PromptProvider
) {
super(grocyProductService, prompt);
}

async importList(id?: string): Promise<void> {
if (!id) {
protected async getProductsToImport(options: ImportOptions): Promise<NewProductPayloads[]> {
let listId;
if (!listId) {
const promptId = await this.listService.promptSelectOrCreateList();
if (!promptId) return;
id = promptId;
if (!promptId) return [];
listId = promptId;
}
const list = await this.listService.getList(id);
const list = await this.listService.getList(listId);
const existingProducts = await this.grocyProductService.getAllProducts();
const existingProductIds = existingProducts
.filter((p) => p.userfields?.storeMetadata?.PNS)
Expand All @@ -42,25 +50,44 @@ export class FoodstuffsListImporter {
this.logger.info("All products have already been imported");
}
const parentProducts = Object.values(await this.grocyParentProductService.getParentProducts());
const newProducts: { id: string; product: FoodstuffsListProduct }[] = [];
return firstValueFrom(
from(productsToImport).pipe(
switchMap((product) =>
this.grocyParentProductService
.promptForMatchingParent$(product.name, product.category, parentProducts)
.pipe(switchMap((parent) => this.converter.forImportListProduct(product, parent)))
),
toArray()
)
);
}

for (const product of productsToImport) {
const parent = await this.grocyParentProductService.promptForMatchingParent(
product.name,
product.category,
parentProducts
);
const payloads = await this.converter.forImportListProduct(product, parent);
this.logger.info(`Importing product ${payloads.product.name}...`);
const createdProduct = await this.grocyProductService.createProduct(
payloads.product,
payloads.quConversions
);
newProducts.push({ id: createdProduct.id, product });
}
const stock = await this.prompt.confirm("Stock imported products?");
if (stock) {
await this.stockProductsFromList(list);
protected async convertToStockRequest(
importResult: ImportProductsResult
): Promise<ProductToStock[]> {
// throw new Error("Method not implemented.");
const productsByPnsId = await this.getProductsByFoodstuffsId();
// Not including unavailable products for stock
for (const product of list.products) {
const grocyProduct = productsByPnsId[product.productId];
if (!grocyProduct) {
this.logger.error(
`Product ${product.productId} (${product.name}) does not exist in Grocy, skipping`
);
continue;
}
this.logger.info("Stocking product: " + grocyProduct.name);
try {
const addStockRequest = await this.converter.forAddStock(grocyProduct);
await this.grocyStockService.addStock(grocyProduct.id, addStockRequest);
} catch (error) {
this.logger.error("Error stocking product");
if (error instanceof RequestError) {
this.logger.error(await error.response.text());
} else {
this.logger.error(error);
}
}
}
}

Expand Down
11 changes: 10 additions & 1 deletion src/store/foodstuffs/grocy/import/receipt-importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,16 @@ export class FoodstuffsReceiptImporter {
*/
async importReceiptListRefs(listRefs: Record<string, ListProductRef>, listId: string) {
await this.foodstuffsListService.addProductsToList(listId, Object.values(listRefs));
await this.listImporter.importList(listId);
await this.listImporter.importProducts({
listId,
vendor: "pns",
source: "list",
});
// todo - support stocking imported products in superclass
// and implement in listImporter
if (await this.prompt.confirm("Stock imported products?")) {
await this.listImporter.stockProductsFromList(listId);
}
this.logger.info("Adding receipt metadata to imported items...");
const productsByPnsId = await this.listImporter.getProductsByFoodstuffsId();
for (const [name, ref] of Object.entries(listRefs)) {
Expand Down
Loading

0 comments on commit 6cb7b0d

Please sign in to comment.