diff --git a/package.json b/package.json index 286cddb..c69a22c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@google/clasp": "^2.4.2", "@jessie.js/eslint-plugin": "^0.4.0", "@types/better-sqlite3": "^7.6.1", - "@types/google-apps-script": "^1.0.83", + "@types/google-apps-script": "^1.0.97", "@types/node": "^14.14.7", "@typescript-eslint/parser": "^4.21.0", "eslint-config-airbnb-base": "^14.2.1", diff --git a/packages/gc-sync1/.gitignore b/packages/gc-sync1/.gitignore new file mode 100644 index 0000000..8052860 --- /dev/null +++ b/packages/gc-sync1/.gitignore @@ -0,0 +1,2 @@ +simple-checkbook.gnucash +*.gnucash.*.log diff --git a/packages/gc-sync1/Makefile b/packages/gc-sync1/Makefile new file mode 100644 index 0000000..a98eb55 --- /dev/null +++ b/packages/gc-sync1/Makefile @@ -0,0 +1,38 @@ +SQLITE3 = sqlite3 +# determined with +# gnucash --debug --extra +# per https://wiki.gnucash.org/wiki/Python_shell +PYINIT=/usr/share/gnucash/python/init.py +# +# tested with +# https://packages.ubuntu.com/jammy/gnucash 1:4.8-1build2 +SITE_GUILE=/usr/share/guile/site/3.0/ +GUILE=guile +SO_LIBS=/usr/lib/x86_64-linux-gnu/gnucash:/usr/lib/x86_64-linux-gnu/gnucash/gnucash:/usr/lib/x86_64-linux-gnu + +CONFIG=$(HOME)/.config/gnucash/config-user.scm +# (gnc-build-userdata-path "...") +USERDATA=$(HOME)/.local/share/gnucash/ + + +simple-checkbook.sql: simple-checkbook.gnucash + $(SQLITE3) $< .dump >$@ || (rm $@; exit 1) + +# dead end. no way to add menu item to run python code +pyshell-enable: + echo change if False: to if True: + EDITOR=gedit sudoedit $(PYINIT) + +repl: + LD_LIBRARY_PATH=$(SO_LIBS) $(GUILE) -L $(SITE_GUILE) + +SITE_GUILE=/usr/share/guile/site/3.0/ +GUILE=guile +SO_LIBS=/usr/lib/x86_64-linux-gnu/gnucash:/usr/lib/x86_64-linux-gnu/gnucash/gnucash:/usr/lib/x86_64-linux-gnu +check: sync-uncat-lib.scm + LD_LIBRARY_PATH=$(SO_LIBS) $(GUILE) -L $(SITE_GUILE) $< + +# Debugging +# gnucash --debug --log gnc.scm=debug +# per https://wiki.gnucash.org/wiki/Custom_Reports#Debugging_your_report + diff --git a/packages/gc-sync1/README.md b/packages/gc-sync1/README.md new file mode 100644 index 0000000..3b589e4 --- /dev/null +++ b/packages/gc-sync1/README.md @@ -0,0 +1,30 @@ +# gc-sync1 - Categorize Splits from GnuCash UI + +**STATUS: WIP**. see [finquick issue +#51](https://github.com/dckc/finquick/issues/51). + +Provided GnuCash is configured as below, we add these menu items: + + - Push Uncat Txs - select uncategorized transactions and POST to a + Google Sheet for categorization (_working_). + - Pull Categories - GET categories and apply them (_not working_). + +## Load GnuCash Menu Handlers + +Following conventions for [loading a custom +report](https://wiki.gnucash.org/wiki/Custom_Reports#Loading_Your_Report), +put something like this in `~/.config/gnucash/config-user.scm`: + +```scm +(load (gnc-build-userdata-path "sync-uncat.scm")) +``` + +Then symlink `~/.local/share/gnucash/sync-uncat.scm` and +`~/.local/share/gnucash/sync-uncat-lib.scm` to the code in this dir. + +## Deploy Google Sheet web service + +Add `../sync26/syncSvc.js` to your spreadsheet and [create a +deployment](https://developers.google.com/apps-script/concepts/deployments). +This produces a URL like `https://script.google.com/macros/s/as;ldkflsd...`. +Assign that to the `$FINSYNC` environment variable before starting `gnucash`. diff --git a/packages/gc-sync1/config-user.scm b/packages/gc-sync1/config-user.scm new file mode 120000 index 0000000..0070b6d --- /dev/null +++ b/packages/gc-sync1/config-user.scm @@ -0,0 +1 @@ +/home/connolly/.config/gnucash/config-user.scm \ No newline at end of file diff --git a/packages/gc-sync1/simple-checkbook.sql b/packages/gc-sync1/simple-checkbook.sql new file mode 100644 index 0000000..aa480a0 --- /dev/null +++ b/packages/gc-sync1/simple-checkbook.sql @@ -0,0 +1,94 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE gnclock ( Hostname varchar(255), PID int ); +INSERT INTO gnclock VALUES('bldbox',1033466); +CREATE TABLE versions(table_name text(50) PRIMARY KEY NOT NULL, table_version integer NOT NULL); +INSERT INTO versions VALUES('Gnucash',4000008); +INSERT INTO versions VALUES('Gnucash-Resave',19920); +INSERT INTO versions VALUES('books',1); +INSERT INTO versions VALUES('commodities',1); +INSERT INTO versions VALUES('accounts',1); +INSERT INTO versions VALUES('budgets',1); +INSERT INTO versions VALUES('budget_amounts',1); +INSERT INTO versions VALUES('prices',3); +INSERT INTO versions VALUES('transactions',4); +INSERT INTO versions VALUES('splits',5); +INSERT INTO versions VALUES('slots',4); +INSERT INTO versions VALUES('recurrences',2); +INSERT INTO versions VALUES('schedxactions',1); +INSERT INTO versions VALUES('lots',2); +INSERT INTO versions VALUES('billterms',2); +INSERT INTO versions VALUES('customers',2); +INSERT INTO versions VALUES('employees',2); +INSERT INTO versions VALUES('entries',4); +INSERT INTO versions VALUES('invoices',4); +INSERT INTO versions VALUES('jobs',1); +INSERT INTO versions VALUES('orders',1); +INSERT INTO versions VALUES('taxtables',2); +INSERT INTO versions VALUES('taxtable_entries',3); +INSERT INTO versions VALUES('vendors',1); +CREATE TABLE books(guid text(32) PRIMARY KEY NOT NULL, root_account_guid text(32) NOT NULL, root_template_guid text(32) NOT NULL); +INSERT INTO books VALUES('c732dff174484eeeb1b3baabe05edd16','aba44add598e462f9dc10937be1bb72b','8ed484f757d24820a1283c2b74e3c127'); +CREATE TABLE commodities(guid text(32) PRIMARY KEY NOT NULL, namespace text(2048) NOT NULL, mnemonic text(2048) NOT NULL, fullname text(2048), cusip text(2048), fraction integer NOT NULL, quote_flag integer NOT NULL, quote_source text(2048), quote_tz text(2048)); +INSERT INTO commodities VALUES('07eec0750dcc461687208f33ceae5022','CURRENCY','USD','US Dollar','840',100,1,'currency',''); +CREATE TABLE accounts(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, account_type text(2048) NOT NULL, commodity_guid text(32), commodity_scu integer NOT NULL, non_std_scu integer NOT NULL, parent_guid text(32), code text(2048), description text(2048), hidden integer, placeholder integer); +INSERT INTO accounts VALUES('aba44add598e462f9dc10937be1bb72b','Root Account','ROOT','07eec0750dcc461687208f33ceae5022',100,0,NULL,'','',0,0); +INSERT INTO accounts VALUES('6d4ed96d03d74632959fe0b04bec7280','Assets','ASSET','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','Assets',0,1); +INSERT INTO accounts VALUES('5add42c3affe4b27b55fabe1892b3968','Current Assets','ASSET','07eec0750dcc461687208f33ceae5022',100,0,'6d4ed96d03d74632959fe0b04bec7280','','Current Assets',0,1); +INSERT INTO accounts VALUES('c54efcecd14444d3b332537e19bbf773','Checking Account','BANK','07eec0750dcc461687208f33ceae5022',100,0,'5add42c3affe4b27b55fabe1892b3968','','Checking Account',0,0); +INSERT INTO accounts VALUES('2a9693d8210648bbad01adef84b260d9','Income','INCOME','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','Income',0,0); +INSERT INTO accounts VALUES('5aa9099417ee48218b1677b2d2420426','Expenses','EXPENSE','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','Expenses',0,0); +INSERT INTO accounts VALUES('fb4f394e1bf74b77942a645f9d9bc18e','Equity','EQUITY','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','Equity',0,1); +INSERT INTO accounts VALUES('ea375d587dcb4116b517728deefaa1e8','Opening Balances','EQUITY','07eec0750dcc461687208f33ceae5022',100,0,'fb4f394e1bf74b77942a645f9d9bc18e','','Opening Balances',0,0); +INSERT INTO accounts VALUES('8ed484f757d24820a1283c2b74e3c127','Template Root','ROOT',NULL,0,0,NULL,'','',0,0); +INSERT INTO accounts VALUES('fc27e99a2a294f4c80013ebbdc7f448d','Discretionary','EXPENSE','07eec0750dcc461687208f33ceae5022',100,0,'5aa9099417ee48218b1677b2d2420426','','',0,0); +INSERT INTO accounts VALUES('ca3e6da80599482b9c87f226649f2956','Imbalance-USD','BANK','07eec0750dcc461687208f33ceae5022',100,0,'aba44add598e462f9dc10937be1bb72b','','',0,0); +CREATE TABLE budgets(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, description text(2048), num_periods integer NOT NULL); +CREATE TABLE budget_amounts(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, budget_guid text(32) NOT NULL, account_guid text(32) NOT NULL, period_num integer NOT NULL, amount_num bigint NOT NULL, amount_denom bigint NOT NULL); +CREATE TABLE prices(guid text(32) PRIMARY KEY NOT NULL, commodity_guid text(32) NOT NULL, currency_guid text(32) NOT NULL, date text(19) NOT NULL, source text(2048), type text(2048), value_num bigint NOT NULL, value_denom bigint NOT NULL); +CREATE TABLE transactions(guid text(32) PRIMARY KEY NOT NULL, currency_guid text(32) NOT NULL, num text(2048) NOT NULL, post_date text(19), enter_date text(19), description text(2048)); +INSERT INTO transactions VALUES('0540b8bd52f746e19f7c0fb8ed105804','07eec0750dcc461687208f33ceae5022','','2025-05-26 10:59:00','2025-05-26 19:59:49','Dime Store'); +INSERT INTO transactions VALUES('dbc07db225df4958810a21b3b07cb04d','07eec0750dcc461687208f33ceae5022','','2025-05-01 10:59:00','2025-05-26 20:00:44','Factory'); +INSERT INTO transactions VALUES('78e0f219767a4f538b228165f8944efd','07eec0750dcc461687208f33ceae5022','','2025-05-26 10:59:00','2025-05-26 21:02:24','Burger Place'); +CREATE TABLE splits(guid text(32) PRIMARY KEY NOT NULL, tx_guid text(32) NOT NULL, account_guid text(32) NOT NULL, memo text(2048) NOT NULL, action text(2048) NOT NULL, reconcile_state text(1) NOT NULL, reconcile_date text(19), value_num bigint NOT NULL, value_denom bigint NOT NULL, quantity_num bigint NOT NULL, quantity_denom bigint NOT NULL, lot_guid text(32)); +INSERT INTO splits VALUES('763369e1d87241a9a78b585cd11b4d36','0540b8bd52f746e19f7c0fb8ed105804','5aa9099417ee48218b1677b2d2420426','','','n','1970-01-01 00:00:00',1000,100,1000,100,NULL); +INSERT INTO splits VALUES('5b54138912c54a4d80d2a805649dfeb3','0540b8bd52f746e19f7c0fb8ed105804','c54efcecd14444d3b332537e19bbf773','','','n','1970-01-01 00:00:00',-1000,100,-1000,100,NULL); +INSERT INTO splits VALUES('6578409bddbd4184a52130886f13a1c6','dbc07db225df4958810a21b3b07cb04d','c54efcecd14444d3b332537e19bbf773','','','n','1970-01-01 00:00:00',10000,100,10000,100,NULL); +INSERT INTO splits VALUES('2900d6c6962c41e1ab680e8a241825fc','dbc07db225df4958810a21b3b07cb04d','2a9693d8210648bbad01adef84b260d9','','','n','1970-01-01 00:00:00',-10000,100,-10000,100,NULL); +INSERT INTO splits VALUES('ef2050a1fc8d4a5580dcb322181f6e13','78e0f219767a4f538b228165f8944efd','c54efcecd14444d3b332537e19bbf773','','','n','1970-01-01 00:00:00',-1200,100,-1200,100,NULL); +INSERT INTO splits VALUES('39cedc6b8c654eea851f03d410957498','78e0f219767a4f538b228165f8944efd','fc27e99a2a294f4c80013ebbdc7f448d','','','n','1970-01-01 00:00:00',1200,100,1200,100,NULL); +CREATE TABLE slots(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, obj_guid text(32) NOT NULL, name text(4096) NOT NULL, slot_type integer NOT NULL, int64_val bigint, string_val text(4096), double_val float8, timespec_val text(19), guid_val text(32), numeric_val_num bigint, numeric_val_denom bigint, gdate_val text(8)); +INSERT INTO slots VALUES(4,'6d4ed96d03d74632959fe0b04bec7280','placeholder',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(5,'5add42c3affe4b27b55fabe1892b3968','placeholder',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(6,'fb4f394e1bf74b77942a645f9d9bc18e','placeholder',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(7,'ea375d587dcb4116b517728deefaa1e8','equity-type',4,0,'opening-balance',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(13,'c732dff174484eeeb1b3baabe05edd16','counter_formats',9,0,NULL,NULL,'1970-01-01 00:00:00','3435a86e684b463c8cb73606a277f7b3',0,1,NULL); +INSERT INTO slots VALUES(14,'c732dff174484eeeb1b3baabe05edd16','features',9,0,NULL,NULL,'1970-01-01 00:00:00','1770b480378a4f368ff2e086caf49edf',0,1,NULL); +INSERT INTO slots VALUES(15,'1770b480378a4f368ff2e086caf49edf','features/ISO-8601 formatted date strings in SQLite3 databases.',4,0,'Use ISO formatted date-time strings in SQLite3 databases (requires at least GnuCash 2.6.20)',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(16,'1770b480378a4f368ff2e086caf49edf','features/Register sort and filter settings stored in .gcm file',4,0,'Store the register sort and filter settings in .gcm metadata file (requires at least GnuCash 3.3)',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(17,'c732dff174484eeeb1b3baabe05edd16','options',9,0,NULL,NULL,'1970-01-01 00:00:00','1a5b524359dc4cf48a7cbd9b91426854',0,1,NULL); +INSERT INTO slots VALUES(18,'1a5b524359dc4cf48a7cbd9b91426854','options/Budgeting',9,0,NULL,NULL,'1970-01-01 00:00:00','38c32ecd53e9485f92db2ec7de359bbc',0,1,NULL); +INSERT INTO slots VALUES(19,'c732dff174484eeeb1b3baabe05edd16','remove-color-not-set-slots',4,0,'true',NULL,'1970-01-01 00:00:00',NULL,0,1,NULL); +INSERT INTO slots VALUES(20,'0540b8bd52f746e19f7c0fb8ed105804','date-posted',10,0,NULL,NULL,'1970-01-01 00:00:00',NULL,0,1,'20250526'); +INSERT INTO slots VALUES(21,'dbc07db225df4958810a21b3b07cb04d','date-posted',10,0,NULL,NULL,'1970-01-01 00:00:00',NULL,0,1,'20250501'); +INSERT INTO slots VALUES(22,'78e0f219767a4f538b228165f8944efd','date-posted',10,0,NULL,NULL,'1970-01-01 00:00:00',NULL,0,1,'20250526'); +CREATE TABLE recurrences(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, obj_guid text(32) NOT NULL, recurrence_mult integer NOT NULL, recurrence_period_type text(2048) NOT NULL, recurrence_period_start text(8) NOT NULL, recurrence_weekend_adjust text(2048) NOT NULL); +CREATE TABLE schedxactions(guid text(32) PRIMARY KEY NOT NULL, name text(2048), enabled integer NOT NULL, start_date text(8), end_date text(8), last_occur text(8), num_occur integer NOT NULL, rem_occur integer NOT NULL, auto_create integer NOT NULL, auto_notify integer NOT NULL, adv_creation integer NOT NULL, adv_notify integer NOT NULL, instance_count integer NOT NULL, template_act_guid text(32) NOT NULL); +CREATE TABLE lots(guid text(32) PRIMARY KEY NOT NULL, account_guid text(32), is_closed integer NOT NULL); +CREATE TABLE billterms(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, description text(2048) NOT NULL, refcount integer NOT NULL, invisible integer NOT NULL, parent text(32), type text(2048) NOT NULL, duedays integer, discountdays integer, discount_num bigint, discount_denom bigint, cutoff integer); +CREATE TABLE customers(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, id text(2048) NOT NULL, notes text(2048) NOT NULL, active integer NOT NULL, discount_num bigint NOT NULL, discount_denom bigint NOT NULL, credit_num bigint NOT NULL, credit_denom bigint NOT NULL, currency text(32) NOT NULL, tax_override integer NOT NULL, addr_name text(1024), addr_addr1 text(1024), addr_addr2 text(1024), addr_addr3 text(1024), addr_addr4 text(1024), addr_phone text(128), addr_fax text(128), addr_email text(256), shipaddr_name text(1024), shipaddr_addr1 text(1024), shipaddr_addr2 text(1024), shipaddr_addr3 text(1024), shipaddr_addr4 text(1024), shipaddr_phone text(128), shipaddr_fax text(128), shipaddr_email text(256), terms text(32), tax_included integer, taxtable text(32)); +CREATE TABLE employees(guid text(32) PRIMARY KEY NOT NULL, username text(2048) NOT NULL, id text(2048) NOT NULL, language text(2048) NOT NULL, acl text(2048) NOT NULL, active integer NOT NULL, currency text(32) NOT NULL, ccard_guid text(32), workday_num bigint NOT NULL, workday_denom bigint NOT NULL, rate_num bigint NOT NULL, rate_denom bigint NOT NULL, addr_name text(1024), addr_addr1 text(1024), addr_addr2 text(1024), addr_addr3 text(1024), addr_addr4 text(1024), addr_phone text(128), addr_fax text(128), addr_email text(256)); +CREATE TABLE entries(guid text(32) PRIMARY KEY NOT NULL, date text(19) NOT NULL, date_entered text(19), description text(2048), action text(2048), notes text(2048), quantity_num bigint, quantity_denom bigint, i_acct text(32), i_price_num bigint, i_price_denom bigint, i_discount_num bigint, i_discount_denom bigint, invoice text(32), i_disc_type text(2048), i_disc_how text(2048), i_taxable integer, i_taxincluded integer, i_taxtable text(32), b_acct text(32), b_price_num bigint, b_price_denom bigint, bill text(32), b_taxable integer, b_taxincluded integer, b_taxtable text(32), b_paytype integer, billable integer, billto_type integer, billto_guid text(32), order_guid text(32)); +CREATE TABLE invoices(guid text(32) PRIMARY KEY NOT NULL, id text(2048) NOT NULL, date_opened text(19), date_posted text(19), notes text(2048) NOT NULL, active integer NOT NULL, currency text(32) NOT NULL, owner_type integer, owner_guid text(32), terms text(32), billing_id text(2048), post_txn text(32), post_lot text(32), post_acc text(32), billto_type integer, billto_guid text(32), charge_amt_num bigint, charge_amt_denom bigint); +CREATE TABLE jobs(guid text(32) PRIMARY KEY NOT NULL, id text(2048) NOT NULL, name text(2048) NOT NULL, reference text(2048) NOT NULL, active integer NOT NULL, owner_type integer, owner_guid text(32)); +CREATE TABLE orders(guid text(32) PRIMARY KEY NOT NULL, id text(2048) NOT NULL, notes text(2048) NOT NULL, reference text(2048) NOT NULL, active integer NOT NULL, date_opened text(19) NOT NULL, date_closed text(19) NOT NULL, owner_type integer NOT NULL, owner_guid text(32) NOT NULL); +CREATE TABLE taxtables(guid text(32) PRIMARY KEY NOT NULL, name text(50) NOT NULL, refcount bigint NOT NULL, invisible integer NOT NULL, parent text(32)); +CREATE TABLE taxtable_entries(id integer PRIMARY KEY AUTOINCREMENT NOT NULL, taxtable text(32) NOT NULL, account text(32) NOT NULL, amount_num bigint NOT NULL, amount_denom bigint NOT NULL, type integer NOT NULL); +CREATE TABLE vendors(guid text(32) PRIMARY KEY NOT NULL, name text(2048) NOT NULL, id text(2048) NOT NULL, notes text(2048) NOT NULL, currency text(32) NOT NULL, active integer NOT NULL, tax_override integer NOT NULL, addr_name text(1024), addr_addr1 text(1024), addr_addr2 text(1024), addr_addr3 text(1024), addr_addr4 text(1024), addr_phone text(128), addr_fax text(128), addr_email text(256), terms text(32), tax_inc text(2048), tax_table text(32)); +DELETE FROM sqlite_sequence; +INSERT INTO sqlite_sequence VALUES('slots',22); +CREATE INDEX tx_post_date_index ON transactions(post_date); +CREATE INDEX splits_tx_guid_index ON splits(tx_guid); +CREATE INDEX splits_account_guid_index ON splits(account_guid); +CREATE INDEX slots_guid_index ON slots(obj_guid); +COMMIT; diff --git a/packages/gc-sync1/sync-uncat-lib.scm b/packages/gc-sync1/sync-uncat-lib.scm new file mode 100644 index 0000000..cb6222f --- /dev/null +++ b/packages/gc-sync1/sync-uncat-lib.scm @@ -0,0 +1,147 @@ +;; sync-uncat.scm +;; Synchronize uncategorized splits from external data +;; +;; Copyright (C) 2025 by Dan Connolly +;; SPDX-License-Identifier: Apache-2.0 +;; Share and Enjoy. + +(define-module (sync-uncat-lib)) +(use-modules ((srfi srfi-1) #:select (remove every))) +(use-modules ((srfi srfi-11) #:select (let-values))) +(use-modules ((srfi srfi-71) #:select (let*))) +(use-modules ((gnucash core-utils) #:select (N_))) +(use-modules ((gnucash utilities) #:select + (gnc:msg gnc:debug gnc:warn gnc:gui-msg))) +(use-modules ((gnucash report report-utilities) #:select (gnc:strify))) +(use-modules ((web client) #:select (http-request))) +(use-modules ((web response) #:select (response-code response-headers))) +(use-modules ((gnucash json builder) #:select (scm->json-string))) +;; #:select doesn't work for xaccTransGetDate etc. +;; maybe due to FFI magic in (gnucash engine)? +(use-modules (gnucash engine)) ;; #select ( +;; xaccSplitSetAccount xaccSplitGet* xaccTransGet* xaccAccountGet* +;; gnc-account-lookup-by-name gnc-account-get-children-sorted gnc-print-time64) +;; (load-and-reexport (sw_app_utils) ...) +(use-modules (gnucash app-utils)) ;; #:select (gnc-get-current-book gnc-get-current-root-account) + +(export run-push-tx-ids) +(export run-pull-categories) + +;; Uncategorized transaction data structure for use with scm->json +;; See also EXPECTED_POST_BODY_FORMAT in syncSvc.js +(define (valid-transaction? obj) + (and (list? obj) + (every pair? obj) + (let ((guid (assoc "guid" obj)) + (date (assoc "date" obj)) + (account (assoc "account" obj)) + (description (assoc "description" obj)) + (amount (assoc "amount" obj))) + (and guid (string? (cdr guid)) + date (string? (cdr date)) + account (string? (cdr account)) + description (string? (cdr description)) + amount (number? (cdr amount)))))) + +(define (split-record split) + (let* ((parent (xaccSplitGetParent split)) + (other (xaccSplitGetOtherSplit split)) + (account-code (xaccAccountGetCode (xaccSplitGetAccount other))) + (amount (xaccSplitGetAmount split)) + (time64 (xaccTransGetDate parent)) + (datetime-str (gnc-print-time64 time64 "%Y-%m-%d %H:%M:%S")) + ) + ;; JSON builder object + `(("date" . ,datetime-str) + ("account" . ,account-code) + ("description" . ,(xaccTransGetDescription parent)) + ;; exact->inexact is a little scary + ;; how about #(,(numerator amount) "/" ,(denominator amount))? + ("amount" . ,amount) + ("guid" . ,(gncTransGetGUID parent)) + ))) + +(define* (uncat-splits root #:key (name "Imbalance-USD")) + (xaccAccountGetSplitList (gnc-account-lookup-by-name root name))) + +(define (explore-gnucash-api) + (let* ((book (gnc-get-current-book)) + (root (gnc-get-current-root-account)) + (accts (gnc-account-get-children-sorted root)) + (acct-uncat (gnc-account-lookup-by-name root "Imbalance-USD"))) + ;; (format #t ...) would likely obviate (display ...) + (format #t "book: ~a\n" book) + (format #t "root: ~a\n" root) + (format #t "accts: ~s\n" (map (lambda (a) (xaccAccountGetName a)) accts)) + (format #t "uncat: ~a\n" (gnc:strify acct-uncat)))) + +(define (logged label x) + (format #t label x) + x) + +(define* (http-post* url #:key (method 'POST) (body #f) (headers '())) + (let-values (((resp resp-body) + (http-request url #:method method #:headers headers #:body body))) + (if (memq (response-code (logged "resp ~a~%" resp)) '(301 302 303)) + (let ((url2 (cdr (assoc 'location (response-headers resp))))) + ;; switch to GET on redirect + (http-request (logged "redirect to ~a~%" url2) + #:method 'GET #:headers headers #:body body)) + (values resp body)))) + +(define (run-push-tx-ids window) + ;; (display "hi from run-push-tx-ids\n") + (explore-gnucash-api) + (let* ((root (gnc-get-current-root-account)) + (records (logged "uncat records sexp: ~%~a~%" (map split-record (uncat-splits root)))) + (invalid-records (remove valid-transaction? records)) + (data `(("transactions" . ,(list->vector records))))) + (unless (null? invalid-records) + (error "bad records:" invalid-records)) + (gnc:gui-msg "?" (format #f "found ~a uncategorized transactions to POST" (length records))) + (let-values (((response response-body) + (http-post* (logged "XXX ambient env URL: ~a~%" (getenv "FINSYNC")) + #:method 'POST + #:headers `((content-type . (application/json))) + #:body (logged "uncat records JSON: ~%~a~%" + (scm->json-string data #:pretty #t))))) + (unless (eqv? (response-code response) 200) + (format #t "error body: ~a~%" response-body) + (error "unexpected response" response))) + (gnc:gui-msg "?" "POSTed") + )) + +(define cups-home "http://localhost:631") ; HTTP server that happens to be handy + +(define* (fetch-stuff #:key (url cups-home)) + (gnc:warn "XXX ambient net access. TODO: inject\n") + (let* ((response body (http-request url))) + ;; TODO: error handling + ;; (if (not (eql (response-code response) 200)) + ;; (error (response-reason-phrase response))) + body + ) + ) + +;; experimentally verify that we can sert the "Category" of 1 split. +(define (sync1 root) + (let* ((uncat (gnc-account-lookup-by-name root "Imbalance-USD")) + (cat (gnc-account-lookup-by-name root "Discretionary")) + (haystack (xaccAccountGetSplitList uncat))) + (unless (null? haystack) + (let ((needle (car haystack))) + (xaccSplitSetAccount needle cat))))) + +(define (run-pull-categories window) + (display "hi from run-pull-categories\n") + (let* ( + (root (gnc-get-current-root-account)) + (acct-uncat (gnc-account-lookup-by-name root "Imbalance-USD") ) + ) + (format #t "root: ~a\n" root) + (format #t "uncat: ~a\n" (gnc:strify acct-uncat)) + (let ((body (fetch-stuff))) + (gnc:gui-msg "XXX" (format #f "HTTP GET: length: ~a" (string-length body)))) + (sync1 root) + (gnc:gui-msg "XXX unused?" "didn't crash: sync1\n") + )) diff --git a/packages/gc-sync1/sync-uncat.scm b/packages/gc-sync1/sync-uncat.scm new file mode 100644 index 0000000..f6e8157 --- /dev/null +++ b/packages/gc-sync1/sync-uncat.scm @@ -0,0 +1,33 @@ +;; sync-uncat.scm +;; Synchronize uncategorized splits from external data + +(load (gnc-build-userdata-path "sync-uncat-lib.scm")) +(use-modules (sync-uncat-lib)) + +(define (handle-exn key . args) + ;; Handle or log the error + (format (current-error-port) "Error in extension: ~a ~a~%" key args) + ;; Optionally show GUI feedback + (gnc:gui-msg "?" (format #f "Extension Failed: ~a" args))) + +;; Register the menu items +(gnc-add-scm-extension + (gnc:make-menu-item + (N_ "Push Uncat Txs") ; Name that appears in the menu + "0d9fe0a6-de1b-4de5-a27c-1919cd9fe484" + (N_ "Push uncategorized splits to SheetSync") ; Tooltip/Description + (list (N_ "Tools")) ; Path: "Tools" menu + (lambda (window) + (catch #t (lambda () (run-push-tx-ids window)) handle-exn)) + )) + +(gnc-add-scm-extension + (gnc:make-menu-item + (N_ "Pull Categories") + "c397d0f6-7876-4efd-a2b2-8eabd51ab63a" + (N_ "Pull categories from SheetSync") + (list (N_ "Tools")) + (lambda (window) + (catch #t (lambda () (run-pull-categories window)) handle-exn)) + )) + diff --git a/packages/sync26/gncSync.js b/packages/sync26/gncSync.js new file mode 100644 index 0000000..0d3497e --- /dev/null +++ b/packages/sync26/gncSync.js @@ -0,0 +1,194 @@ +const sheetsConfig = { + plaid: { + name: 'Transactions (2)', + cols: { + gncIdTarget: 'tx_guid', + }, + }, + gnucash: { + name: 'GnuCash_Uncat', + cols: { + plaidIdTarget: 'Transaction ID', + }, + }, + accounts: { + name: 'Accounts', + }, +}; + +/** + * @typedef {{ + * Date: Date, + * Amount: number, + * 'Account #': string, + * tx_guid: string, + * 'Transaction ID': string, + * Account: string, + * }} PlaidTransactionRecord + */ + +/** + * @typedef {{ + * date: Date, + * account: string, + * amount: number, + * tx_guid: string, + * plaid_id?: string, + * }} GnuCashTransactionRecord + */ + +/** + * @typedef {{ + * code: string, + * Account: string, + * }} AccountRecord + */ + +/** + * @typedef {{ + * gncRecord: GnuCashTransactionRecord, + * plaidMatch: PlaidTransactionRecord, + * gncIndex: number, // 0-based index in the recordsToProcess (first 10) array + * plaidIndex: number, // 0-based index in the full plaidRecords array + * }} MatchResult + */ + +/** + * Main controller function. Fetches data, orchestrates the matching process, and writes back the IDs. + * + * @param {object} io Injected dependencies for testability/I/O. + */ +const SyncGnuCashPlaidTxns = (_nonce, io = {}) => { + const { + doc = SpreadsheetApp.getActive(), + activeRange = SpreadsheetApp.getActiveRange(), + gncSheet = doc.getSheetByName(sheetsConfig.gnucash.name), + plaidSheet = doc.getSheetByName(sheetsConfig.plaid.name), + accountsSheet = doc.getSheetByName(sheetsConfig.accounts.name), + } = io; + + // Get Sheets and All Records + if (!gncSheet || !plaidSheet || !accountsSheet) { + throw Error('Required sheets not found. Check sheet names in config.'); + } + const { records: plaidRecords } = getSheetRecords(plaidSheet, 2); + const { records: accountRecords } = getSheetRecords(accountsSheet, 2); + const { records: gncRecords } = getSheetRecords(gncSheet); + + /** @type {Map} */ + const codeToPlaidAcctNameMap = new Map( + accountRecords + .map(record => { + /** @type {AccountRecord} */ + const aRecord = /** @type {AccountRecord} */ (record); + return [String(aRecord.code), aRecord.Account]; + }) + .filter(([, plaidAcctName]) => plaidAcctName), + ); + + // 1-indexed, including header + const [lo, hi] = [activeRange.getRow(), activeRange.getLastRow()]; + const recordsToProcess = gncRecords.slice(lo - 2, hi - 1); + + const matches = findTransactionMatches( + recordsToProcess, + plaidRecords, + codeToPlaidAcctNameMap, + ); + + // Write Back Results + const gncPlaidIdCol = getColumnNumber( + gncSheet, + sheetsConfig.gnucash.cols.plaidIdTarget, + ); + const plaidGncIdCol = getColumnNumber( + plaidSheet, + sheetsConfig.plaid.cols.gncIdTarget, + ); + for (const match of matches) { + // Calculate 1-based row numbers, based on activeRange + const gncRowNum = match.gncIndex + lo; + const plaidRowNum = match.plaidIndex + 2; + + const plaidTxId = match.plaidMatch['Transaction ID']; + + gncSheet.getRange(gncRowNum, gncPlaidIdCol).setValue(plaidTxId); + plaidSheet + .getRange(plaidRowNum, plaidGncIdCol) + .setValue(match.gncRecord.tx_guid); + } + + // Final Status Report + console.log( + `Successfully synced ${matches.length} out of ${recordsToProcess.length}`, + ); +}; + +const SyncGnuCashPlaidTxnsTest = (nonce, io = {}) => { + const { + doc = SpreadsheetApp.getActive(), + gncSheet = doc.getSheetByName(sheetsConfig.gnucash.name), + activeRange = gncSheet.getRange('A3:G5'), + } = io; + return SyncGnuCashPlaidTxns(nonce, { ...io, doc, gncSheet, activeRange }); +}; + +/** + * Checks if two Date objects are within a specified number of days of each other. + * + * @param {Date} date1 + * @param {Date} date2 + * @param {number} maxDays - The maximum allowable difference in days (e.g., 2 for +/- 2 days). + */ +const fuzzyDateMatch = (date1, date2, maxDays) => { + const diffTime = Math.abs(date1.getTime() - date2.getTime()); + return diffTime <= maxDays * DAY; +}; + +/** + * Core matching engine. Finds corresponding Plaid transactions for a list of GnuCash transactions. + * + * @param {GnuCashTransactionRecord[]} gncRecordsToProcess - The GnuCash records to iterate over. + * @param {PlaidTransactionRecord[]} allPlaidRecords - All Plaid records to search against. + * @param {Map} codeToPlaidAcctNameMap - Map from GnuCash code to Plaid Account Name. + * @returns {MatchResult[]} An array of objects containing the matched records and their original indices. + */ +const findTransactionMatches = ( + gncRecordsToProcess, + allPlaidRecords, + codeToPlaidAcctNameMap, +) => { + /** @type {MatchResult[]} */ + const matches = []; + + for (let gncIndex = 0; gncIndex < gncRecordsToProcess.length; gncIndex++) { + const gncRecord = gncRecordsToProcess[gncIndex]; + if (gncRecord['Transaction Id']) continue; // already matched + + const requiredPlaidAcctName = codeToPlaidAcctNameMap.get( + String(gncRecord.account), + ); + if (!requiredPlaidAcctName) continue; + + const foundMatch = allPlaidRecords.find(plaidRecord => { + if (plaidRecord.tx_guid) return false; // already matched + if (plaidRecord.Account !== requiredPlaidAcctName) return false; + if (gncRecord.amount !== -plaidRecord.Amount) return false; + return fuzzyDateMatch(plaidRecord.Date, gncRecord.date, 2.67); + }); + + // Record Match and Indices + if (foundMatch) { + const plaidIndex = allPlaidRecords.indexOf(foundMatch); + + matches.push({ + gncRecord, + plaidMatch: foundMatch, + gncIndex, + plaidIndex, + }); + } + } + + return matches; +}; diff --git a/packages/sync26/menu.js b/packages/sync26/menu.js index b6c753a..b0de1c1 100644 --- a/packages/sync26/menu.js +++ b/packages/sync26/menu.js @@ -9,6 +9,7 @@ function onOpen() { ui.createMenu('Family Finances') .addItem('Tx Lookup', 'TxLookup') .addItem('Tx: Apply Rules', 'ApplyRules') + .addItem('Gnc: Sync w/Plaid', 'SyncGnuCashPlaidTxns') .addItem('Load Trade Accounting', 'loadTradeAccountingMessages') .addItem('Share Just In Case', 'shareJustInCase') .addSubMenu( diff --git a/packages/sync26/polyfill/fetch.js b/packages/sync26/polyfill/fetch.js new file mode 100644 index 0000000..a6b1d39 --- /dev/null +++ b/packages/sync26/polyfill/fetch.js @@ -0,0 +1,16 @@ +'use strict'; + +function makeFetch() { + const { freeze } = Object; + + console.warn('AMBIENT: UrlFetchApp'); + const app = UrlFetchApp; + + const fetch = async url => { + const content = app.fetch(url); + return freeze({ + json: async () => JSON.parse(content), + }); + }; + return fetch; +} diff --git a/packages/sync26/sheetTools.js b/packages/sync26/sheetTools.js index 26b6436..78044ef 100644 --- a/packages/sync26/sheetTools.js +++ b/packages/sync26/sheetTools.js @@ -4,23 +4,30 @@ function GetAllSheetNames() { return sheets.map(sheet => sheet.getName()); } -function setRange(sheet, hd, rows, hdRow = 1, detailRow = hdRow + 1) { +/** + * @param {GoogleAppsScript.Spreadsheet.Spreadsheet} sheet + * @param {string[]} hd + * @param {string[][]} rows + * @param {number} [hdRow] + * @param {number} [detailRow] + */ +/* export */ function setRange(sheet, hd, rows, hdRow = 1, detailRow = hdRow + 1) { sheet.getRange(hdRow, 1, 1, hd.length).setValues([hd]); sheet.getRange(detailRow, 1, rows.length, hd.length).setValues(rows); } const zip = (xs, ys) => xs.map((x, ix) => [x, ys[ix]]); -/* export */ function getRowRecord(sheet, row, headings) { +function getRowRecord(sheet, row, headings) { const [values] = sheet.getRange(row, 1, 1, headings.length).getValues(); const entries = zip(headings, values); return Object.fromEntries(entries); } -/* export */ function getHeading(sheet) { +function getHeading(sheet, col1 = 1) { const hd = []; for ( - let col = 1, name; + let col = col1, name; (name = sheet.getRange(1, col).getValue()) > ''; col += 1 ) { @@ -29,9 +36,11 @@ const zip = (xs, ys) => xs.map((x, ix) => [x, ys[ix]]); return hd; } -function getSheetRecords(sheet) { - const hd = getHeading(sheet); - const data = sheet.getRange(2, 1, sheet.getLastRow(), hd.length).getValues(); +function getSheetRecords(sheet, col1 = 1) { + const hd = getHeading(sheet, col1); + const data = sheet + .getRange(2, col1, sheet.getLastRow(), hd.length) + .getValues(); const records = []; for (const values of data) { const entries = zip(hd, values); diff --git a/packages/sync26/syncSvc.js b/packages/sync26/syncSvc.js new file mode 100644 index 0000000..7d79bcf --- /dev/null +++ b/packages/sync26/syncSvc.js @@ -0,0 +1,273 @@ +/** + * syncSvc -- HTTP handlers for synchronizing + * GnuCash transactions with a Google Sheets in SheetSync format. + * + * @see {doPost} for pushing uncategorized transactions + * @see {doGet} for pulling categories + */ +// @ts-check + +// --- Configuration --- +const config = { + sheetUncat: { + name: 'GnuCash_Uncat', + hd: ['date', 'account', 'description', 'amount', 'tx_guid', 'uploaded'], + }, + /** Name of your SheetSync Transactions sheet */ + sheetTx: 'Transactions (2)', + // see also getProperty(...) below + idProp: 'GOOGLE_SHEET_ID', +}; + +/** + * Expected format for the POST request body for `doPost`. + */ +const EXPECTED_POST_BODY_FORMAT = { + transactions: [ + { + guid: 'deadbeef...', + date: '2020-01-02', + account: '1234', // account code + description: 'payee etc.', + amount: 123.45, + }, + ], +}; + +/** + * @typedef {typeof EXPECTED_POST_BODY_FORMAT} UncategorizedTransaction + */ + +/** + * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet + * @param {string[]} hd + * @param {(string|number|Date|boolean)[][]} rows + * @param {number} [hdRow] + * @param {number} [detailRow] + */ +function setRange(sheet, hd, rows, hdRow = 1, detailRow = hdRow + 1) { + sheet.getRange(hdRow, 1, 1, hd.length).setValues([hd]); + sheet.getRange(detailRow, 1, rows.length, hd.length).setValues(rows); +} + +/** + * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet + * @param {string} name + */ +function getColumnNumber(sheet, name) { + const [hd] = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues(); + const colIx = hd.indexOf(name); + assert(colIx >= 0, `no such column: ${name}`); + return colIx + 1; +} + +/** + * @param {GoogleAppsScript.Spreadsheet.Sheet} sheet + * @param {string} name + */ +function getColumn(sheet, name) { + const column = getColumnNumber(sheet, name); + const rows = sheet + .getRange(2, column, sheet.getLastRow(), column) + .getValues(); + return rows.map(([val]) => val); +} + +/** + * @param {unknown} requestBody + * @param {object} io + * @param {GoogleAppsScript.Spreadsheet.Sheet} io.sheetUncat + * @param {() => number} io.now + * @param {(...args: any[]) => void} [io.log] + */ +const saveUploaded = ( + requestBody, + { sheetUncat, now, log = (...args) => {} }, +) => { + assert(requestBody); + assert.typeof(requestBody, 'object'); + /** @type {UncategorizedTransaction['transactions']} */ + const uncategorizedTransactions = requestBody.transactions; + + if ( + !uncategorizedTransactions || + !Array.isArray(uncategorizedTransactions) || + uncategorizedTransactions.length === 0 + ) { + throw Error('No transactions provided in the request body.'); + } + + log( + `Received ${uncategorizedTransactions.length} uncategorized transactions from GnuCash.`, + ); + + const ts = new Date(now()); // Timestamp for when it was received + + const rows = uncategorizedTransactions.map(tx => [ + tx.date, + tx.account, + tx.description, + tx.amount, + tx.guid, + ts, + ]); + + setRange(sheetUncat, config.sheetUncat.hd, rows); + log(`Saved`, rows.length, `transactions to sheet`, sheetUncat.getName()); + return rows.length; +}; + +/** + * Handles HTTP POST requests. + * This function receives uncategorized transactions from the GnuCash extension. + * It ONLY saves the transactions to the temporary sheet. + * + * Expected request body (JSON): {@link UncategorizedTransaction} + * + * @param {Pick} e The event object from the POST request. + * @returns {GoogleAppsScript.Content.TextOutput} A JSON response indicating success or failure. + */ +function doPost(e, io = {}) { + try { + const { + now = Date.now, + props = PropertiesService.getScriptProperties(), + sheetId = NonNullish( + props.getProperty(config.idProp), + 'GOOGLE_SHEET_ID not set in Project Properties.', + ), + spreadsheet = SpreadsheetApp.openById(sheetId), + sheetUncat = spreadsheet.getSheetByName(config.sheetUncat.name), + } = io; + + // Parse the JSON payload from the request body + const requestBody = JSON.parse(e.postData.contents); + assert(sheetUncat); + const saved = saveUploaded(requestBody, { sheetUncat, now }); + return createJsonResponse({ code: 200, saved }); + } catch (error) { + console.error('Error in doPost: ', error); + return createJsonResponse({ code: 500, message: `Internal error.` }); + } +} + +function doPostTest() { + const contents = JSON.stringify(EXPECTED_POST_BODY_FORMAT); + doPost({ + postData: { contents, length: contents.length, name: '?', type: '?/?' }, + }); +} + +/** + * Handles HTTP GET requests. + * This function returns a list of transaction GUIDs and their categories + * from the Main Transactions sheet. + * + * @param {GoogleAppsScript.Events.DoGet} _e The event object from the GET request. + * @param {object} [io] + * @param {GoogleAppsScript.Properties.Properties} [io.props] + * @param {string} [io.idProp] + * @param {string} [io.sheetId] + * @param {GoogleAppsScript.Spreadsheet.Spreadsheet} [io.spreadsheet] + * @param {GoogleAppsScript.Spreadsheet.Sheet} [io.sheetUncat] + * @param {GoogleAppsScript.Spreadsheet.Sheet} [io.sheetTx] + * @returns {GoogleAppsScript.Content.TextOutput} A JSON response containing categories. + */ +function doGet(_e, io = {}) { + try { + const { + props = PropertiesService.getScriptProperties(), + sheetId = NonNullish( + props.getProperty(config.idProp), + `no such project property: ${config.idProp}`, + ), + spreadsheet = SpreadsheetApp.openById(sheetId), + sheetUncat = NonNullish( + spreadsheet.getSheetByName(config.sheetUncat.name), + `no such sheet: ${config.sheetUncat.name}`, + ), + sheetTx = NonNullish( + spreadsheet.getSheetByName(config.sheetTx), + `no such sheet: ${config.sheetTx}`, + ), + } = io; + + const uncatGuids = getColumn(sheetUncat, 'tx_guid').filter(g => g > ''); + const txData = sheetTx.getDataRange().getValues(); + const txHd = txData[0]; + const txRows = txData.slice(1); + const guidIx = getColumnNumber(sheetTx, 'tx_guid') - 1; + const wanted = txRows + .filter(row => uncatGuids.includes(row[guidIx])) + .map(([v0, dt, ...rest]) => [v0, dt.toISOString(), ...rest]); + const result = { hd: txHd, rows: wanted }; + + console.log({ + uncatGuids: uncatGuids.length, + txRows: txRows.length, + wanted: wanted.length, + }); + return createJsonResponse({ code: 200, ...result }); + } catch (error) { + console.error('Error in doGet: ', error); + return createJsonResponse({ code: 500, message: `Internal error` }); + } +} + +/** + * Helper function to create a JSON response. + * @param {object} data The data to include in the JSON response. + * @returns {GoogleAppsScript.Content.TextOutput} A ContentService TextOutput object. + */ +function createJsonResponse(data) { + const output = ContentService.createTextOutput(JSON.stringify(data)); + output.setMimeType(ContentService.MimeType.JSON); + return output; +} + +const typeofToType = /** @type {const} */ ({ + undefined: undefined, + boolean: true, + number: 123, + string: 'abc', + object: /** @type {Record|null} */ ({}), +}); + +/** + * @type {((cond: unknown, msg?: string) => asserts cond) & { + * typeof: (specimen: unknown, ty: K, msg?: string) => asserts specimen is typeof typeofToType[K] + * }} + */ +const assert = (() => { + const a0 = (cond, msg) => { + if (!cond) throw Error(msg || 'condition failed'); + }; + /** + * + * @template {keyof typeof typeofToType} K + * @param {unknown} specimen + * @param {K} ty + * @param {string} [msg] + * @returns {asserts specimen is typeof typeofToType[K]} + */ + const ty = (specimen, ty, msg) => { + const actual = typeof specimen; + if (actual !== ty) throw Error(msg || `expected ${ty}; got ${actual}`); + }; + const { assign, freeze } = Object; + const it = assign(a0, { typeof: ty }); + freeze(it); + return it; +})(); + +/** + * @template T + * @param {T | null | undefined} val + * @param {string} [optDetails] + * @returns {T} + */ +const NonNullish = (val, optDetails = `unexpected ${val}`) => { + assert(val, optDetails); + return val; +}; diff --git a/packages/sync26/txRules.js b/packages/sync26/txRules.js index 59fca13..09de2ed 100644 --- a/packages/sync26/txRules.js +++ b/packages/sync26/txRules.js @@ -67,3 +67,13 @@ function ApplyRules() { if (!sel) return; applyRulesToRange(doc, sel); } + +function TestGetSelection(_nonce, io = { sheetName: 'Accounts' }) { + const { + doc = SpreadsheetApp.getActive(), + sheetName = Rules.sheetName, + sheet = doc.getSheetByName(sheetName), + } = io; + const { records } = getSheetRecords(sheet, 2); + console.log(records.slice(0, 3)); +} diff --git a/yarn.lock b/yarn.lock index 910b4a1..ebf00be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -347,10 +347,10 @@ __metadata: languageName: node linkType: hard -"@types/google-apps-script@npm:^1.0.83": - version: 1.0.83 - resolution: "@types/google-apps-script@npm:1.0.83" - checksum: 10c0/57ee29f3dd252859435dfbb254b6f1098c4abc71ae9f296075b4e8e487de19e98785328671a07397394c88f2fa348d60bdbbf560182dfea5e384fdbd7756c8f9 +"@types/google-apps-script@npm:^1.0.97": + version: 1.0.97 + resolution: "@types/google-apps-script@npm:1.0.97" + checksum: 10c0/c4f6e36c89ee70bda2662c0c84616f95b9a1882dfb81f264a04a8a226770e13a3be342179f743d5dea2128257654982e226cc5fec18affee394dc34a7832f6dd languageName: node linkType: hard @@ -4798,7 +4798,7 @@ __metadata: "@google/clasp": "npm:^2.4.2" "@jessie.js/eslint-plugin": "npm:^0.4.0" "@types/better-sqlite3": "npm:^7.6.1" - "@types/google-apps-script": "npm:^1.0.83" + "@types/google-apps-script": "npm:^1.0.97" "@types/node": "npm:^14.14.7" "@typescript-eslint/parser": "npm:^4.21.0" eslint-config-airbnb-base: "npm:^14.2.1"