sheets initial commit

This commit is contained in:
Anantha Kumaran 2023-12-24 21:16:22 +05:30
parent 8236a09fbd
commit 062bb8bf40
34 changed files with 1542 additions and 124 deletions

View File

@ -13,3 +13,5 @@ package-lock.json
yarn.lock
src/lib/search/parser/parser.terms.js
src/lib/search/parser/parser.js
src/lib/sheet/parser.terms.js
src/lib/sheet/parser.js

View File

@ -320,6 +320,10 @@ func GetJournalPath() string {
return config.JournalPath
}
func GetSheetDir() string {
return filepath.Dir(GetJournalPath())
}
func GetDBPath() string {
if !filepath.IsAbs(config.DBPath) {
return filepath.Join(GetConfigDir(), config.DBPath)

View File

@ -277,6 +277,45 @@ func Build(db *gorm.DB, enableCompression bool) *gin.Engine {
c.JSON(200, SaveFile(db, ledgerFile))
})
router.GET("/api/sheets/files", func(c *gin.Context) {
c.JSON(200, GetSheets(db))
})
router.POST("/api/sheets/file", func(c *gin.Context) {
var sheetFile SheetFile
if err := c.ShouldBindJSON(&sheetFile); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(200, GetSheet(sheetFile))
})
router.POST("/api/sheets/file/delete_backups", func(c *gin.Context) {
var sheetFile SheetFile
if err := c.ShouldBindJSON(&sheetFile); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(200, DeleteSheetBackups(sheetFile))
})
router.POST("/api/sheets/save", func(c *gin.Context) {
if config.GetConfig().Readonly {
c.JSON(200, gin.H{"saved": false, "message": "Readonly mode"})
return
}
var sheetFile SheetFile
if err := c.ShouldBindJSON(&sheetFile); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(200, SaveSheetFile(db, sheetFile))
})
router.GET("/api/account/tf_idf", func(c *gin.Context) {
c.JSON(200, prediction.GetTfIdf(db))
})

148
internal/server/sheet.go Normal file
View File

@ -0,0 +1,148 @@
package server
import (
"path/filepath"
"sort"
"time"
"os"
"github.com/ananthakumaran/paisa/internal/config"
"github.com/bmatcuk/doublestar/v4"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
const EXTENSION = ".paisa"
type SheetFile struct {
Name string `json:"name"`
Content string `json:"content"`
Versions []string `json:"versions"`
Operation string `json:"operation"`
}
func GetSheets(db *gorm.DB) gin.H {
dir := config.GetSheetDir()
paths, _ := doublestar.FilepathGlob(dir + "/**/*" + EXTENSION)
files := []*SheetFile{}
for _, path := range paths {
files = append(files, readSheetFileWithVersions(dir, path))
}
return gin.H{"files": files}
}
func GetSheet(file SheetFile) gin.H {
dir := config.GetSheetDir()
return gin.H{"file": readSheetFile(dir, filepath.Join(dir, file.Name))}
}
func DeleteSheetBackups(file SheetFile) gin.H {
dir := config.GetSheetDir()
if !config.GetConfig().Readonly {
versions, _ := filepath.Glob(filepath.Join(dir, file.Name+".backup.*"))
for _, version := range versions {
err := os.Remove(version)
if err != nil {
log.Fatal(err)
}
}
}
return gin.H{"file": readSheetFileWithVersions(dir, filepath.Join(dir, file.Name))}
}
func SaveSheetFile(db *gorm.DB, file SheetFile) gin.H {
dir := config.GetSheetDir()
filePath := filepath.Join(dir, file.Name)
backupPath := filepath.Join(dir, file.Name+".backup."+time.Now().Format("2006-01-02-15-04-05.000"))
err := os.MkdirAll(filepath.Dir(filePath), 0700)
if err != nil {
log.Warn(err)
return gin.H{"saved": false, "message": "Failed to create directory"}
}
fileStat, err := os.Stat(filePath)
if err != nil && file.Operation != "overwrite" && file.Operation != "create" {
log.Warn(err)
return gin.H{"saved": false, "message": "File does not exist"}
}
var perm os.FileMode = 0644
if err == nil {
if file.Operation == "create" {
return gin.H{"saved": false, "message": "File already exists"}
}
perm = fileStat.Mode().Perm()
existingContent, err := os.ReadFile(filePath)
if err != nil {
log.Warn(err)
return gin.H{"saved": false, "message": "Failed to read file"}
}
err = os.WriteFile(backupPath, existingContent, perm)
if err != nil {
log.Warn(err)
return gin.H{"saved": false, "message": "Failed to create backup"}
}
}
err = os.WriteFile(filePath, []byte(file.Content), perm)
if err != nil {
log.Warn(err)
return gin.H{"saved": false, "message": "Failed to write file"}
}
return gin.H{"saved": true, "file": readSheetFileWithVersions(dir, filePath)}
}
func readSheetFile(dir string, path string) *SheetFile {
content, err := os.ReadFile(path)
if err != nil {
log.Fatal(err)
}
name, err := filepath.Rel(dir, path)
return &SheetFile{
Name: name,
Content: string(content),
}
}
func readSheetFileWithVersions(dir string, path string) *SheetFile {
content, err := os.ReadFile(path)
if err != nil {
log.Fatal(err)
}
versions, _ := filepath.Glob(filepath.Join(filepath.Dir(path), filepath.Base(path)+".backup.*"))
versionPaths := lo.Map(versions, func(path string, _ int) string {
name, err := filepath.Rel(dir, path)
if err != nil {
log.Fatal(err)
}
return name
})
sort.Sort(sort.Reverse(sort.StringSlice(versionPaths)))
name, err := filepath.Rel(dir, path)
if err != nil {
log.Fatal(err)
}
return &SheetFile{
Name: name,
Content: string(content),
Versions: versionPaths,
}
}

14
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@lezer/lr": "^1.3.10",
"@types/json-schema": "^7.0.12",
"arima": "^0.2.5",
"bignumber.js": "^9.1.2",
"bulma": "^0.9.4",
"bulma-switch": "^2.0.4",
"bulma-toast": "^2.4.2",
@ -3251,6 +3252,14 @@
}
]
},
"node_modules/bignumber.js": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz",
"integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==",
"engines": {
"node": "*"
}
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@ -14231,6 +14240,11 @@
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true
},
"bignumber.js": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz",
"integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug=="
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",

View File

@ -3,8 +3,8 @@
"version": "0.0.1",
"private": true,
"scripts": {
"parser-build": "lezer-generator src/lib/search/parser/query.grammar -o src/lib/search/parser/parser",
"parser-build-debug": "lezer-generator src/lib/search/parser/query.grammar --names -o src/lib/search/parser/parser",
"parser-build": "lezer-generator src/lib/sheet/language.grammar -o src/lib/sheet/parser && lezer-generator src/lib/search/parser/query.grammar -o src/lib/search/parser/parser",
"parser-build-debug": "lezer-generator src/lib/sheet/language.grammar --names -o src/lib/sheet/parser && lezer-generator src/lib/search/parser/query.grammar --names -o src/lib/search/parser/parser",
"dev": "vite dev --host 0.0.0.0",
"build": "vite build",
"build:watch": "vite build --watch",
@ -28,6 +28,7 @@
"@lezer/lr": "^1.3.10",
"@types/json-schema": "^7.0.12",
"arima": "^0.2.5",
"bignumber.js": "^9.1.2",
"bulma": "^0.9.4",
"bulma-switch": "^2.0.4",
"bulma-toast": "^2.4.2",

View File

@ -208,6 +208,10 @@ svg text {
fill: $grey-lighter;
}
.has-background-grey-lightest {
background-color: $grey-lightest;
}
.svg-text-grey-light {
fill: $grey-light;
}
@ -374,6 +378,16 @@ svg text {
box-shadow: $shadow;
}
.box.box-r-none {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.box.box-l-none {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.box {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
}
@ -522,7 +536,7 @@ nav.level.grid-2 {
}
.cm-activeLineGutter {
background-color: $grey-light !important;
background-color: $grey-lightest !important;
color: $grey-dark;
font-weight: bold;
}
@ -679,6 +693,14 @@ nav.level.grid-2 {
height: 200px;
}
.sheet-result {
background-color: $white-ter;
color: $grey;
}
.sheet-editor .cm-editor {
}
.search-query-editor {
border: 1px solid $grey-lighter;
border-radius: $radius;

View File

@ -5,6 +5,7 @@
export let label = "Save As";
export let help = "Create or overwrite existing file";
export let placeholder = "expense.ledger";
export let open = false;
let destinationFile = "";
@ -19,7 +20,7 @@
<div class="field" slot="body">
<label class="label" for="save-filename">File Name</label>
<div class="control" id="save-filename">
<input class="input" type="text" placeholder="expense.ledger" bind:value={destinationFile} />
<input class="input" type="text" {placeholder} bind:value={destinationFile} />
<p class="help">{help}</p>
</div>
</div>

View File

@ -1,9 +1,9 @@
<script lang="ts">
import type { LedgerDirectory, LedgerFile } from "$lib/utils";
import type { Directory, LedgerFile } from "$lib/utils";
import _ from "lodash";
import { createEventDispatcher } from "svelte";
export let files: Array<LedgerDirectory | LedgerFile>;
export let files: Array<Directory | LedgerFile>;
export let path: string;
export let selectedFileName: string;
export let hasUnsavedChanges: boolean;
@ -19,7 +19,7 @@
return _.filter(paths, (p) => !_.isEmpty(p)).join("/");
}
function isOpen(file: LedgerDirectory | LedgerFile) {
function isOpen(file: Directory | LedgerFile) {
const fullPath = join([path, file.name]);
return selectedFileName?.startsWith(fullPath);
}

View File

@ -117,6 +117,7 @@
href: "/more",
children: [
{ label: "Configuration", href: "/config", tag: "alpha", help: "config" },
{ label: "Sheets", href: "/sheets" },
{ label: "Goals", href: "/goals", help: "goals" },
{ label: "Doctor", href: "/doctor" },
{ label: "Logs", href: "/logs" }

View File

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { parse, render, asRows } from "./sheet";
import { parse, render, asRows } from "./spreadsheet";
import fs from "fs";
import helpers from "./template_helpers";
import _ from "lodash";

View File

@ -1,6 +1,6 @@
import { styleTags, tags as t } from "@lezer/highlight";
export const jsonHighlighting = styleTags({
export const queryHighlighting = styleTags({
Quoted: t.string,
UnQuoted: t.string,
Number: t.number,

View File

@ -1,6 +1,6 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {jsonHighlighting} from "./highlight"
import {queryHighlighting} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "$bQVQPOOOOQO'#C`'#C`O!WQQO'#CdOOQO'#C_'#C_OOQO'#Cg'#CgO!]QPO'#CfOOQO'#C{'#C{O!qQPO'#DOOVQPO'#CwOVQPO'#C}OOQO'#C^'#C^QVQPOOOOQO'#DP'#DPO$OQQO,59OOOQO'#Cp'#CpO$WQPO,59QOOQO'#Cx'#CxOVQPO,59cOOQO,59c,59cO$iQPO,59iOOQO-E6|-E6|OOQO-E6}-E6}OOQO1G.j1G.jOOQO1G.l1G.lOOQO1G.}1G.}OOQO1G/T1G/T",
@ -8,7 +8,7 @@ export const parser = LRParser.deserialize({
goto: "#gtPPu!R!^PPP!^P!g!oPPPPPPPP!wPPPPPP!g!zPP!}P!g#V#aWVOXZcQbWRha[YOWXZacRg__ROWXZ_ac]YOWXZac]TOWXZacR_TRaV]WOWXZacQZOQcXTdZcQ]QRe]",
nodeNames: "⚠ Query Clause Value String UnQuoted Quoted Number DateValue RegExp Condition Property Account Commodity Amount Total Filename Note Payee Date Operator = =~ > >= < <= BooleanCondition BooleanBinaryOperator AND OR BooleanUnaryOperator NOT Expression",
maxTerm: 43,
propSources: [jsonHighlighting],
propSources: [queryHighlighting],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData: "!!_~RsXY#`YZ#`]^#`pq#`rs#exy%}yz&S}!O&X!O!P(T!P!Q(Y!Q!R+d!R![+t!^!_,X!_!`,f!`!a,s!c!d-Q!d!p/b!p!q/{!q!r1t!r!}/b!}#O3O#P#Q3T#T#U3Y#U#V/b#V#W:t#W#X@c#X#Y/b#Y#ZBv#Z#b/b#b#cGs#c#d/b#d#eJZ#e#h/b#h#iM]#i#o/b~#eOv~~#hWpq#eqr#ers$Qs#O#e#O#P$V#P;'S#e;'S;=`%w<%lO#e~$VOU~~$YXrs#e!P!Q#e#O#P#e#U#V#e#Y#Z#e#b#c#e#f#g#e#h#i#e#i#j$u~$xR!Q![%R!c!i%R#T#Z%R~%UR!Q![%_!c!i%_#T#Z%_~%bR!Q![%k!c!i%k#T#Z%k~%nR!Q![#e!c!i#e#T#Z#e~%zP;=`<%l#e~&SO{~~&XOz~R&^QyQ!Q!R&d!R!['rP&iRVP!O!P&r!g!h'W#X#Y'WP&uP!Q![&xP&}RVP!Q![&x!g!h'W#X#Y'WP'ZR{|'d}!O'd!Q!['jP'gP!Q!['jP'oPVP!Q!['jP'wSVP!O!P&r!Q!['r!g!h'W#X#Y'WQ(YOyQR(_WyQOY(wZ!P(w!Q!}(w!}#O*O#O#P*}#P;'S(w;'S;=`+^<%lO(wP(zXOY(wZ!P(w!P!Q)g!Q!}(w!}#O*O#O#P*}#P;'S(w;'S;=`+^<%lO(wP)lUXP#Z#[)g#]#^)g#a#b)g#g#h)g#i#j)g#m#n)gP*RVOY*OZ#O*O#O#P*h#P#Q(w#Q;'S*O;'S;=`*w<%lO*OP*kSOY*OZ;'S*O;'S;=`*w<%lO*OP*zP;=`<%l*OP+QSOY(wZ;'S(w;'S;=`+^<%lO(wP+aP;=`<%l(wR+kRyQVP!O!P&r!g!h'W#X#Y'WR+{SyQVP!O!P&r!Q!['r!g!h'W#X#Y'W~,^Pi~!_!`,a~,fOj~~,kPe~#r#s,n~,sOf~~,xPg~!_!`,{~-QOh~R-XWyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!p-q!p!q.Y!q!}-q#T#o-qP-vUTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qP._WTP!O!P-q!P!Q-q!Q![-q![!]-q!c!f-q!f!g.w!g!}-q#T#o-qP/OUmPTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qR/iUyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qR0SWyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!q-q!q!r0l!r!}-q#T#o-qP0qWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!v-q!v!w1Z!w!}-q#T#o-qP1bUpPTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qR1{WyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!t-q!t!u2e!u!}-q#T#o-qP2lUnPTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-q~3TOx~~3YOw~R3aYyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#V-q#V#W4P#W#a-q#a#b7q#b#o-qP4UWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#V-q#V#W4n#W#o-qP4sWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#c-q#c#d5]#d#o-qP5bWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#i-q#i#j5z#j#o-qP6PWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#b-q#b#c6i#c#o-qP6nWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#h-q#h#i7W#i#o-qP7_U[PTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qP7vWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#c-q#c#d8`#d#o-qP8eWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#i-q#i#j8}#j#o-qP9SWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#b-q#b#c9l#c#o-qP9qWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#h-q#h#i:Z#i#o-qP:bU^PTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qR:{WyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#c-q#c#d;e#d#o-qP;jWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#a-q#a#b<S#b#o-qP<XWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#a-q#a#b<q#b#o-qP<vWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#c-q#c#d=`#d#o-qP=eWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#W-q#W#X=}#X#o-qP>SWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#]-q#]#^>l#^#o-qP>qWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#h-q#h#i?Z#i#o-qP?`WTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#m-q#m#n?x#n#o-qP@PU]PTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qR@jVyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#UAP#U#o-qPAUWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#h-q#h#iAn#i#o-qPAsWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#X-q#X#YB]#Y#o-qPBdUcPTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qRB}WyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#]-q#]#^Cg#^#o-qPClWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#`-q#`#aDU#a#o-qPDZWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#X-q#X#YDs#Y#o-qPDxWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#b-q#b#cEb#c#o-qPEgVTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#UE|#U#o-qPFRWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#a-q#a#bFk#b#o-qPFpWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#X-q#X#YGY#Y#o-qPGaU`PTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qRGzWyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#c-q#c#dHd#d#o-qPHiWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#h-q#h#iIR#i#o-qPIWWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#X-q#X#YIp#Y#o-qPIwUaPTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qRJbVyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#UJw#U#o-qPJ|WTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#m-q#m#nKf#n#o-qPKkWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#X-q#X#YLT#Y#o-qPLYWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#X-q#X#YLr#Y#o-qPLyUbPTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-qRMdWyQTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#c-q#c#dM|#d#o-qPNRWTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#h-q#h#iNk#i#o-qPNpVTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#U! V#U#o-qP! [WTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#`-q#`#a! t#a#o-qP! {U_PTP!O!P-q!P!Q-q!Q![-q![!]-q!c!}-q#T#o-q",

View File

@ -1,5 +1,5 @@
import { describe, test } from "bun:test";
import { jsonLanguage } from "./query";
import { queryLanguage } from "./query";
import { fileTests } from "@lezer/generator/dist/test";
import * as fs from "fs";
@ -13,6 +13,6 @@ for (const file of fs.readdirSync(caseDir)) {
const name = /^[^.]*/.exec(file)[0];
describe(name, () => {
for (const { name, run } of fileTests(fs.readFileSync(path.join(caseDir, file), "utf8"), file))
test(name, () => run(jsonLanguage.parser));
test(name, () => run(queryLanguage.parser));
});
}

View File

@ -61,6 +61,6 @@ DateValue { "[" dateChar+ "]" }
@skip { whitespace }
@external propSource jsonHighlighting from "./highlight"
@external propSource queryHighlighting from "./highlight"
@detectDelim

View File

@ -1,7 +1,7 @@
import { parser } from "./parser";
import { LRLanguage, LanguageSupport } from "@codemirror/language";
export const jsonLanguage = LRLanguage.define({
export const queryLanguage = LRLanguage.define({
name: "query",
parser: parser.configure({}),
languageData: {
@ -10,5 +10,5 @@ export const jsonLanguage = LRLanguage.define({
});
export function queryExtension() {
return new LanguageSupport(jsonLanguage);
return new LanguageSupport(queryLanguage);
}

View File

@ -1,108 +1,97 @@
import Papa from "papaparse";
import * as XLSX from "xlsx";
import { closeBrackets } from "@codemirror/autocomplete";
import { keymap, type KeyBinding } from "@codemirror/view";
import { history, redoDepth, undoDepth } from "@codemirror/commands";
import {
HighlightStyle,
bracketMatching,
syntaxHighlighting,
defaultHighlightStyle,
syntaxTree
} from "@codemirror/language";
import { lintGutter, linter, type Diagnostic } from "@codemirror/lint";
import { tags } from "@lezer/highlight";
import { EditorView } from "codemirror";
import _ from "lodash";
import { format } from "./journal";
import { pdf2array } from "./pdf";
export { sheetEditorState } from "../store";
import { sheetEditorState } from "../store";
import { basicSetup } from "./editor/base";
import { sheetExtension } from "./sheet/language";
import { schedulePlugin } from "./transaction_tag";
interface Result {
data: string[][];
error?: string;
}
import { buildAST } from "./sheet/interpreter";
export function parse(file: File): Promise<Result> {
let extension = file.name.split(".").pop();
extension = extension?.toLowerCase();
if (extension === "csv" || extension === "txt") {
return parseCSV(file);
} else if (extension === "xlsx" || extension === "xls") {
return parseXLSX(file);
} else if (extension === "pdf") {
return parsePDF(file);
}
throw new Error(`Unsupported file type ${extension}`);
}
function lint(editor: EditorView): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
const tree = syntaxTree(editor.state);
export function asRows(result: Result): Array<Record<string, any>> {
return _.map(result.data, (row, i) => {
return _.chain(row)
.map((cell, j) => {
return [String.fromCharCode(65 + j), cell];
})
.concat([["index", i as any]])
.fromPairs()
.value();
});
}
const COLUMN_REFS = _.chain(_.range(65, 90))
.map((i) => String.fromCharCode(i))
.map((a) => [a, a])
.fromPairs()
.value();
export function render(
rows: Array<Record<string, any>>,
template: Handlebars.TemplateDelegate,
options: { reverse?: boolean } = {}
) {
const output: string[] = [];
_.each(rows, (row) => {
const rendered = _.trim(template(_.assign({ ROW: row, SHEET: rows }, COLUMN_REFS)));
if (!_.isEmpty(rendered)) {
output.push(rendered);
tree.cursor().iterate((node) => {
if (node.type.isError) {
diagnostics.push({
from: node.from,
to: node.to,
severity: "error",
message: "Invalid syntax"
});
}
});
if (options.reverse) {
output.reverse();
return diagnostics;
}
export function createEditor(
content: string,
dom: Element,
opts: {
keybindings?: readonly KeyBinding[];
}
return format(output.join("\n\n"));
}
) {
const highlightStyle = HighlightStyle.define(
defaultHighlightStyle.specs.concat([
{ tag: tags.function(tags.variableName), color: "hsl(229, 53%, 53%)" },
{ tag: tags.number, color: "hsl(229, 53%, 53%)", fontWeight: "bold" }
])
);
function parseCSV(file: File): Promise<Result> {
return new Promise((resolve, reject) => {
Papa.parse<string[]>(file, {
skipEmptyLines: true,
complete: function (results) {
resolve(results);
},
error: function (error) {
reject(error);
},
delimitersToGuess: [",", "\t", "|", ";", Papa.RECORD_SEP, Papa.UNIT_SEP, "^"]
});
});
}
async function parseXLSX(file: File): Promise<Result> {
const buffer = await readFile(file);
const sheet = XLSX.read(buffer, { type: "binary" });
const json = XLSX.utils.sheet_to_json<string[]>(sheet.Sheets[sheet.SheetNames[0]], {
header: 1,
blankrows: false,
rawNumbers: false
});
return { data: json };
}
async function parsePDF(file: File): Promise<Result> {
try {
const buffer = await readFile(file);
const array = await pdf2array(buffer);
return { data: array };
} catch (e) {
return { data: [], error: e.message };
}
}
function readFile(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target.result as ArrayBuffer);
};
reader.onerror = (event) => {
reject(event);
};
reader.readAsArrayBuffer(file);
return new EditorView({
extensions: [
keymap.of(opts.keybindings || []),
basicSetup,
syntaxHighlighting(highlightStyle),
bracketMatching(),
closeBrackets(),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
sheetExtension(),
linter(lint),
lintGutter(),
history(),
EditorView.updateListener.of((viewUpdate) => {
const doc = viewUpdate.state.doc.toString();
const currentLine = viewUpdate.state.doc.lineAt(viewUpdate.state.selection.main.head);
sheetEditorState.update((current) => {
let results = current.results;
if (current.doc !== doc) {
const tree = syntaxTree(viewUpdate.state);
try {
const ast = buildAST(tree.topNode, viewUpdate.state);
results = ast.evaluate();
} catch (e) {
// ignore
}
}
return _.assign({}, current, {
results: results,
doc: doc,
currentLine: currentLine.number,
hasUnsavedChanges: current.hasUnsavedChanges || viewUpdate.docChanged,
undoDepth: undoDepth(viewUpdate.state),
redoDepth: redoDepth(viewUpdate.state)
});
});
}),
schedulePlugin
],
doc: content,
parent: dom
});
}

81
src/lib/sheet/cases.txt Normal file
View File

@ -0,0 +1,81 @@
# Number
10000
==>
Sheet(Line(Expression(Literal(Number))))
# Assignment
salary = 3000000
==>
Sheet(Line(Assignment(Identifier,AssignmentOperator,Expression(Literal(Number)))))
# Sum
income = salary + fd
==>
Sheet(Line(Assignment(Identifier,AssignmentOperator,Expression(BinaryExpression(Expression(Identifier),BinaryOperator,Expression(Identifier))))))
# Grouping
edu_cess = (tax + surcharge) * 0.04
==>
Sheet(Line(Assignment(Identifier,AssignmentOperator,Expression(BinaryExpression(Expression(Grouping(Expression(BinaryExpression(Expression(Identifier),BinaryOperator,Expression(Identifier))))),BinaryOperator,Expression(Literal(Number)))))))
# Precedence
-1 + 2 * 3
===>
Sheet(Line(Expression(BinaryExpression(Expression(UnaryExpression(UnaryOperator,Expression(Literal(Number)))),BinaryOperator,Expression(BinaryExpression(Expression(Literal(Number)),BinaryOperator,Expression(Literal(Number))))))))
# Call
sqrt(5.2)
===>
Sheet(Line(Expression(FunctionCall(Identifier,Arguments(Expression(Literal(Number)))))))
# Mutiple Args
pow(5, 2)
===>
Sheet(Line(Expression(FunctionCall(Identifier,Arguments(Expression(Literal(Number)),Expression(Literal(Number)))))))
# No Args
pi()
===>
Sheet(Line(Expression(FunctionCall(Identifier))))
# Search Query
posting(`amount > 0`)
===>
Sheet(Line(Expression(FunctionCall(Identifier,Arguments(Expression(SearchQueryString(Query(Clause(Condition(Property(Amount),Operator(">"),Value(Number)))))))))))
# Function Definition
square(x) = x * 2
===>
Sheet(Line(FunctionDefinition(Identifier,Parameters(Identifier),Expression(BinaryExpression(Expression(Identifier),BinaryOperator,Expression(Literal(Number)))))))

View File

@ -0,0 +1,11 @@
import { styleTags, tags as t } from "@lezer/highlight";
export const sheetHighlighting = styleTags({
String: t.string,
Number: t.number,
Header: t.heading,
"FunctionDefinition/Identifier": t.function(t.variableName),
"FunctionCall/Identifier": t.function(t.variableName),
"( )": t.paren,
"= =~ < > <= >=": t.operator
});

View File

@ -0,0 +1,352 @@
import type { SyntaxNode } from "@lezer/common";
import * as Terms from "./parser.terms";
import type { EditorState } from "@codemirror/state";
import { BigNumber } from "bignumber.js";
import type { SheetLineResult } from "$lib/utils";
const STACK_LIMIT = 1000;
class Environment {
scope: Record<string, any>;
depth: number;
constructor() {
this.scope = {};
this.depth = 0;
}
extend(scope: Record<string, any>): Environment {
const env = new Environment();
env.depth = this.depth + 1;
if (this.depth > STACK_LIMIT) {
throw new Error("Call stack overflow");
}
env.scope = { ...this.scope, ...scope };
return env;
}
}
abstract class AST {
readonly id: number;
constructor(readonly node: SyntaxNode) {
this.id = node.type.id;
}
abstract evaluate(env: Environment): any;
}
class NumberAST extends AST {
readonly value: BigNumber;
constructor(node: SyntaxNode, state: EditorState) {
super(node);
this.value = new BigNumber(state.sliceDoc(node.from, node.to));
}
evaluate(): any {
return this.value;
}
}
class IdentifierAST extends AST {
readonly name: string;
constructor(node: SyntaxNode, state: EditorState) {
super(node);
this.name = state.sliceDoc(node.from, node.to);
}
evaluate(env: Environment): any {
if (env.scope[this.name] === undefined) {
throw new Error(`Undefined variable ${this.name}`);
}
return env.scope[this.name];
}
}
class UnaryExpressionAST extends AST {
readonly operator: string;
readonly value: ExpressionAST;
constructor(node: SyntaxNode, state: EditorState) {
super(node);
this.operator = state.sliceDoc(node.firstChild.from, node.firstChild.to);
this.value = new ExpressionAST(node.lastChild, state);
}
evaluate(env: Environment): any {
switch (this.operator) {
case "-":
return (this.value.evaluate(env) as BigNumber).negated();
default:
throw new Error("Unexpected operator");
}
}
}
class BinaryExpressionAST extends AST {
readonly operator: string;
readonly left: ExpressionAST;
readonly right: ExpressionAST;
constructor(node: SyntaxNode, state: EditorState) {
super(node);
this.left = new ExpressionAST(node.firstChild, state);
this.operator = state.sliceDoc(
node.firstChild.nextSibling.from,
node.firstChild.nextSibling.to
);
this.right = new ExpressionAST(node.lastChild, state);
}
evaluate(env: Environment): any {
switch (this.operator) {
case "+":
return (this.left.evaluate(env) as BigNumber).plus(this.right.evaluate(env));
case "-":
return (this.left.evaluate(env) as BigNumber).minus(this.right.evaluate(env));
case "*":
return (this.left.evaluate(env) as BigNumber).times(this.right.evaluate(env));
case "/":
return (this.left.evaluate(env) as BigNumber).div(this.right.evaluate(env));
case "^":
return (this.left.evaluate(env) as BigNumber).exponentiatedBy(this.right.evaluate(env));
default:
throw new Error("Unexpected operator");
}
}
}
class FunctionCallAST extends AST {
readonly identifier: string;
readonly arguments: ExpressionAST[];
constructor(node: SyntaxNode, state: EditorState) {
super(node);
this.identifier = state.sliceDoc(node.firstChild.from, node.firstChild.to);
this.arguments = childrens(node.firstChild.nextSibling).map(
(node) => new ExpressionAST(node, state)
);
}
evaluate(env: Environment): any {
const fun = env.scope[this.identifier];
if (typeof fun !== "function") {
throw new Error(`Undefined function ${this.identifier}`);
}
return fun(env, ...this.arguments.map((arg) => arg.evaluate(env)));
}
}
class SearchQueryAST extends AST {
readonly value: string;
constructor(node: SyntaxNode, state: EditorState) {
super(node);
this.value = state.sliceDoc(node.firstChild.from, node.firstChild.to);
}
evaluate(): any {
return null;
}
}
class ExpressionAST extends AST {
readonly value:
| NumberAST
| IdentifierAST
| UnaryExpressionAST
| BinaryExpressionAST
| ExpressionAST
| FunctionCallAST
| SearchQueryAST;
constructor(node: SyntaxNode, state: EditorState) {
super(node);
switch (node.firstChild.type.id) {
case Terms.Literal:
switch (node.firstChild.firstChild.type.id) {
case Terms.Number:
this.value = new NumberAST(node.firstChild, state);
break;
default:
throw new Error("Unexpected node type");
}
break;
case Terms.UnaryExpression:
this.value = new UnaryExpressionAST(node.firstChild, state);
break;
case Terms.BinaryExpression:
this.value = new BinaryExpressionAST(node.firstChild, state);
break;
case Terms.Grouping:
this.value = new ExpressionAST(node.firstChild.firstChild, state);
break;
case Terms.Identifier:
this.value = new IdentifierAST(node.firstChild, state);
break;
case Terms.FunctionCall:
this.value = new FunctionCallAST(node.firstChild, state);
break;
case Terms.SearchQueryString:
this.value = new SearchQueryAST(node.firstChild, state);
break;
default:
throw new Error("Unexpected node type");
}
}
evaluate(env: Environment): any {
return this.value.evaluate(env);
}
}
class AssignmentAST extends AST {
readonly identifier: string;
readonly value: ExpressionAST;
constructor(node: SyntaxNode, state: EditorState) {
super(node);
this.identifier = state.sliceDoc(node.firstChild.from, node.firstChild.to);
this.value = new ExpressionAST(node.lastChild, state);
}
evaluate(env: Environment): any {
env.scope[this.identifier] = this.value.evaluate(env);
return env.scope[this.identifier];
}
}
class HeaderAST extends AST {
readonly text: string;
constructor(node: SyntaxNode, state: EditorState) {
super(node);
this.text = state.sliceDoc(node.from, node.to);
}
evaluate(): any {
return this.text;
}
}
class FunctionDefinitionAST extends AST {
readonly identifier: string;
readonly parameters: string[];
readonly body: ExpressionAST;
constructor(node: SyntaxNode, state: EditorState) {
super(node);
this.identifier = state.sliceDoc(node.firstChild.from, node.firstChild.to);
this.parameters = childrens(node.firstChild.nextSibling).map((node) =>
state.sliceDoc(node.from, node.to)
);
this.body = new ExpressionAST(node.lastChild, state);
}
evaluate(env: Environment): any {
env.scope[this.identifier] = (env: Environment, ...args: any[]) => {
const newEnv = env.extend({});
for (let i = 0; i < args.length; i++) {
newEnv.scope[this.parameters[i]] = args[i];
}
return this.body.evaluate(newEnv);
};
return null;
}
}
class LineAST extends AST {
readonly lineNumber: number;
readonly valueId: number;
readonly value: ExpressionAST | AssignmentAST | FunctionDefinitionAST | HeaderAST;
constructor(node: SyntaxNode, state: EditorState) {
super(node);
this.lineNumber = state.doc.lineAt(node.from).number;
const child = node.firstChild;
this.valueId = child.type.id;
switch (child.type.id) {
case Terms.Expression:
this.value = new ExpressionAST(child, state);
break;
case Terms.Assignment:
this.value = new AssignmentAST(child, state);
break;
case Terms.FunctionDefinition:
this.value = new FunctionDefinitionAST(child, state);
break;
case Terms.Header:
this.value = new HeaderAST(child, state);
break;
default:
throw new Error("Unexpected node type");
}
}
evaluate(env: Environment): Record<string, any> {
const value = this.value.evaluate(env);
switch (this.valueId) {
case Terms.Assignment:
case Terms.Expression:
return { result: value?.toString() || "" };
case Terms.FunctionDefinition:
return { result: "" };
case Terms.Header:
return { result: value?.toString() || "", align: "left", bold: true, underline: true };
default:
throw new Error("Unexpected node type");
}
}
}
class SheetAST extends AST {
readonly lines: LineAST[];
constructor(node: SyntaxNode, state: EditorState) {
super(node);
const nodes = childrens(node);
this.lines = [];
for (const node of nodes) {
try {
this.lines.push(new LineAST(node, state));
} catch (e) {
break;
}
}
}
evaluate(env: Environment = new Environment()): SheetLineResult[] {
const results: SheetLineResult[] = [];
let lastLineNumber = 0;
for (const line of this.lines) {
while (line.lineNumber > lastLineNumber + 1) {
results.push({ line: lastLineNumber + 1, error: false, result: "" });
lastLineNumber++;
}
try {
const resultObject = line.evaluate(env);
results.push({ line: line.lineNumber, error: false, ...resultObject } as SheetLineResult);
lastLineNumber++;
} catch (e) {
results.push({ line: line.lineNumber, error: true, result: e.message });
break;
}
}
return results;
}
}
function childrens(node: SyntaxNode): SyntaxNode[] {
if (!node) {
return [];
}
const cur = node.cursor();
const result: SyntaxNode[] = [];
if (!cur.firstChild()) {
return result;
}
do {
result.push(cur.node);
} while (cur.nextSibling());
return result;
}
export function buildAST(node: SyntaxNode, state: EditorState): SheetAST {
return new SheetAST(node, state);
}

View File

@ -0,0 +1,72 @@
@top Sheet { newline* lines? }
@precedence {
call,
unary @right,
factor @left,
term @left,
comparison @left
equality @left
}
lines { Line (newline+ Line)* newline* }
Line { Expression | Assignment | FunctionDefinition | Header }
Expression { Literal | UnaryExpression | BinaryExpression | Grouping | Identifier | FunctionCall | SearchQueryString }
Literal { Number }
Grouping { "(" Expression ")" }
UnaryExpression { !unary UnaryOperator ~unary Expression }
BinaryExpression {
Expression !factor BinaryOperator<"*" | "/"> Expression |
Expression !term BinaryOperator<"+" | "-"> ~unary Expression |
Expression !comparison BinaryOperator<"<" | "<=" | ">" | ">="> Expression
Expression !equality BinaryOperator<"==" | "!="> Expression
}
Assignment { Identifier AssignmentOperator Expression }
FunctionCall { Identifier !call "(" Arguments? ")" }
Arguments { Expression ~call ("," Expression)* }
FunctionDefinition { Identifier !call "(" Parameters? ")" "=" Expression }
Parameters { Identifier ~call ("," Identifier)* }
UnaryOperator { "+" | "-" | "!" }
AssignmentOperator { "=" }
BinaryOperator<expr> { expr }
@tokens {
Number { int frac? exp? }
int { '0' | $[1-9] @digit* }
frac { '.' @digit+ }
exp { $[eE] $[+\-]? @digit+ }
Identifier { $[a-zA-Z_] unquotedchar* }
unquotedchar { $[0-9a-zA-Z:./_] }
whitespace { $[ \t] }
newline { $[\n\r] }
Header { "#" ![\n]* }
}
@local tokens {
stringEnd { '`' }
stringEscape { "\\" _ }
@else stringContent
}
@skip {} {
SearchQueryString { '`' SearchQuery stringEnd }
SearchQuery { (stringContent | stringEscape)* }
}
@skip {
whitespace
}
@external propSource sheetHighlighting from "./highlight"
@detectDelim

21
src/lib/sheet/language.ts Normal file
View File

@ -0,0 +1,21 @@
import { parser } from "./parser";
import { parser as searchQueryParser } from "../search/parser/parser";
import { LRLanguage, LanguageSupport } from "@codemirror/language";
import { parseMixed } from "@lezer/common";
export const sheetLanguage = LRLanguage.define({
name: "sheet",
parser: parser.configure({
wrap: parseMixed((node) => {
if (node.name == "SearchQuery") {
return { parser: searchQueryParser };
}
return null;
})
}),
languageData: {}
});
export function sheetExtension() {
return new LanguageSupport(sheetLanguage);
}

19
src/lib/sheet/parser.js Normal file
View File

@ -0,0 +1,19 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser, LocalTokenGroup} from "@lezer/lr"
import {sheetHighlighting} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "+^QVQPOOOOQO'#Ct'#CtQVQPOOOOQO'#C`'#C`OOQO'#Cc'#CcOtQPO'#CbO!wQPO'#C^OtQPO'#CiO#sQPO'#C_O#}OSO'#CmOOQO'#C_'#C_OOQO'#C^'#C^O$YQPO'#C}QOQPOOOOQO-E6r-E6rOOQO,58|,58|O$bQPO'#C_OOQO'#Ce'#CeOOQO'#Cf'#CfOOQO'#Cg'#CgOtQPO,59OOtQPO,59OOtQPO,59OO${QPO,59TO%hQPO,59VOOQO'#Cp'#CpOtQPO,59ZOOOO'#Cv'#CvO%rOSO'#CnO%}OSO,59XO&SQPO,59iO&ZQPO,59iO&cQPO,59VOOQO1G.j1G.jO'UQPO1G.jO'uQPO1G.jOOQO1G.o1G.oO(pQPO'#ClO)iQPO'#C_O)yQPO1G.qO*nQPO1G.qO*sQPO1G.wO*xQPO1G.uOOOO-E6t-E6tOOQO1G.s1G.sOOQO,59d,59dO+SQPO1G/TOOQO-E6v-E6vOOQO1G.q1G.qO+ZQPO'#CcO,XQQO7+$UO,cQQO'#C_OtQPO'#CuO,mQPO,59WO,uQPO'#CwO,zQPO,59^OtQPO7+$cOOQO7+$]7+$]O-SQPO7+$cPVQPO'#CtOOQO'#Ch'#ChOtQPO<<GpO-XQPO,59aOOQO-E6s-E6sOOQO,59c,59cOOQO-E6u-E6uO-cQPO<<G}OtQPO<<G}O-mQQO1G.jO.cQPOAN=[O/SQPOAN=iO/^QQOAN=[O/hQPO'#CbO/hQPO,59OO/hQPO,59OO/hQPO<<GpO,XQQO7+$UO'uQPO1G.jOtQPO,59O",
stateData: "/o~OoOS~OTRO^WOgZOpPOrSOsSOtSO!OVO!QXO~OTRO^`OrSOsSOtSO!OVO!QXO~OrbOsbOuaOvaOwcOxcOycOzcO~OmQXpQX~P!]OmRXpRXrRXsRXuRXvRXwRXxRXyRXzRX~O!OhO!UiO~P#RO!RkO!SkO!TbP~OpPOmqX~O!OpO}RXTRX^RXtRX!QRX!PRX~P#RO}tO~P!]OTROrSOsSOtSO!OVO!QXO~O^vO}wO~P%SO!RkO!SkO!TbX~O!T|O~Omqa~PVOpPOmqa~O}!QO~PtOuaOvaOrWisWiwWixWiyWizWi~OmWipWi}WiTWi^WitWi!OWi!QWi!PWi~P&jOTRO^!TOr!ROs!ROtSOuaOvaOwcOxcOycOzcO!OVO!QXO~O!P!UO}`X~P!]O!OpOrRXsRXuRXvRXwRXxRXyRXzRX~O!P!WO}RX}fX!PRX~P(zO!U!YOm_ip_ir_is_iu_iv_iw_ix_iy_iz_i~O}!ZO~O}![O~Omcipci~P!]Omqi~PVOTVXTYX^VX^YXrVXrYXsVXsYXtVXtYX!OVX!OYX!QVX!QYX~O{!^O|!^O~P!]O{RX|RX~P(zO!P!UO}`a~O^!bO~O!P!WO}fa~O!U!eO~O}ia!Pia~P!]Omeypey~P!]O{Wi|Wi~P&jOrbOsbOuaOvaOwW!RxW!RyW!RzW!R~OmW!RpW!R}W!RTW!R^W!RtW!R!OW!R!QW!R!PW!R~P-wOme!Rpe!R~P!]O{W!R|W!R~P-wO^!TO~P%SO",
goto: "'OrPPs|#YP#Y#t#Y$b$u%W%g#YP#Y%m#Y%q%t%{%t&OP&R&b&h&n&tPPPP&zS[OQV}n!O!]YUOQn!O!]S_T!jQgVSqd!kQreQsfSuhpQzjQ!SsQ!`!UQ!d!YQ!f!lQ!g!_Q!h!eQ!i!mQ!n!oR!o!p!RYOQTVdefhjnps!O!U!Y!]!_!e!j!k!l!m!o!ptTOQTVdefhjnp!O!U!Y!]!_!e!p]!js!j!k!l!m!ofdUgrsuz!`!d!g!h!oX!k!S!f!i!ndeUgsuz!`!d!g!h!oV!l!S!i!nbfUgsuz!`!d!h!oT!p!S!nQ!_!SR!m!nTxhpRmXZZOQn!O!]RjWRyhQQOW^Qn!O!]Qn[R!OoQ!VuR!a!VQlXR{lQ!XvR!c!XQo[R!PoT]OQ",
nodeNames: "⚠ Sheet Line Expression Literal Number UnaryExpression UnaryOperator BinaryExpression BinaryOperator BinaryOperator BinaryOperator BinaryOperator Grouping Identifier FunctionCall Arguments SearchQueryString SearchQuery Assignment AssignmentOperator FunctionDefinition Parameters Header",
maxTerm: 52,
propSources: [sheetHighlighting],
skippedNodes: [0],
repeatNodeCount: 5,
tokenData: "'e~RfXY!gYZ!l]^!lpq!gqr!qst#Oxy#gyz#lz{#q{|#v|}#{}!O$Q!P!Q$V!Q!R$[!R![%j!^!_%{!_!`&Y!`!a&g!c!}&t#R#S&t#S#T'`#T#o&t~!lOo~~!qOp~U!vPtQ!_!`!yS#OO|S~#TSg~OY#OZ;'S#O;'S;=`#a<%lO#O~#dP;=`<%l#O~#lO!O~~#qO}~~#vOu~~#{Or~~$QO!P~~$VOs~~$[Ov~~$aRT~!O!P$j!g!h%O#X#Y%O~$mP!Q![$p~$uRT~!Q![$p!g!h%O#X#Y%O~%RR{|%[}!O%[!Q![%b~%_P!Q![%b~%gPT~!Q![%b~%oST~!O!P$j!Q![%j!g!h%O#X#Y%O~&QPw~!_!`&T~&YOx~U&_P!UQ!_!`&bS&gO{S~&lPy~!_!`&o~&tOz~~&yV^~!O!P&t!P!Q&t!Q![&t![!]&t!c!}&t#R#S&t#T#o&t~'eO!Q~",
tokenizers: [1, 2, new LocalTokenGroup("x~RQ#O#PX#S#Tr~[RO;'Se;'S;=`j;=`Oe~jO!S~~oP!S~;=`<%le~wO!T~~", 39, 49)],
topRules: {"Sheet":[0,1]},
tokenPrec: 0,
termNames: {"0":"⚠","1":"@top","2":"Line","3":"Expression","4":"Literal","5":"Number","6":"UnaryExpression","7":"UnaryOperator","8":"BinaryExpression","9":"BinaryOperator<\"*\" | \"/\">","10":"BinaryOperator<\"+\" | \"-\">","11":"BinaryOperator<\"<\" | \"<=\" | \">\" | \">=\">","12":"BinaryOperator<\"==\" | \"!=\">","13":"Grouping","14":"Identifier","15":"FunctionCall","16":"Arguments","17":"SearchQueryString","18":"SearchQuery","19":"Assignment","20":"AssignmentOperator","21":"FunctionDefinition","22":"Parameters","23":"Header","24":"newline+","25":"(\",\" Expression)+","26":"(stringContent | stringEscape)+","27":"(\",\" Identifier)+","28":"(newline+ Line)+","29":"␄","30":"%mainskip","31":"whitespace","32":"newline","33":"lines","34":"\"+\"","35":"\"-\"","36":"\"!\"","37":"\"*\"","38":"\"/\"","39":"\"<\"","40":"\"<=\"","41":"\">\"","42":"\">=\"","43":"\"==\"","44":"\"!=\"","45":"\")\"","46":"\"(\"","47":"\",\"","48":"\"`\"","49":"stringContent","50":"stringEscape","51":"stringEnd","52":"\"=\""}
})

View File

@ -0,0 +1,21 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Sheet = 1,
Line = 2,
Expression = 3,
Literal = 4,
Number = 5,
UnaryExpression = 6,
UnaryOperator = 7,
BinaryExpression = 8,
Grouping = 13,
Identifier = 14,
FunctionCall = 15,
Arguments = 16,
SearchQueryString = 17,
SearchQuery = 18,
Assignment = 19,
AssignmentOperator = 20,
FunctionDefinition = 21,
Parameters = 22,
Header = 23

View File

@ -0,0 +1,22 @@
import { describe, test } from "bun:test";
import { sheetLanguage } from "./language";
import { fileTests } from "@lezer/generator/dist/test";
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";
const caseDir = path.dirname(fileURLToPath(import.meta.url));
const parser = sheetLanguage.parser.configure({
strict: false
});
for (const file of fs.readdirSync(caseDir)) {
if (!/\.txt$/.test(file)) continue;
const name = /^[^.]*/.exec(file)[0];
describe(name, () => {
for (const { name, run } of fileTests(fs.readFileSync(path.join(caseDir, file), "utf8"), file))
test(name, () => run(parser));
});
}

104
src/lib/spreadsheet.ts Normal file
View File

@ -0,0 +1,104 @@
import Papa from "papaparse";
import * as XLSX from "xlsx";
import _ from "lodash";
import { format } from "./journal";
import { pdf2array } from "./pdf";
interface Result {
data: string[][];
error?: string;
}
export function parse(file: File): Promise<Result> {
let extension = file.name.split(".").pop();
extension = extension?.toLowerCase();
if (extension === "csv" || extension === "txt") {
return parseCSV(file);
} else if (extension === "xlsx" || extension === "xls") {
return parseXLSX(file);
} else if (extension === "pdf") {
return parsePDF(file);
}
throw new Error(`Unsupported file type ${extension}`);
}
export function asRows(result: Result): Array<Record<string, any>> {
return _.map(result.data, (row, i) => {
return _.chain(row)
.map((cell, j) => {
return [String.fromCharCode(65 + j), cell];
})
.concat([["index", i as any]])
.fromPairs()
.value();
});
}
const COLUMN_REFS = _.chain(_.range(65, 90))
.map((i) => String.fromCharCode(i))
.map((a) => [a, a])
.fromPairs()
.value();
export function render(
rows: Array<Record<string, any>>,
template: Handlebars.TemplateDelegate,
options: { reverse?: boolean } = {}
) {
const output: string[] = [];
_.each(rows, (row) => {
const rendered = _.trim(template(_.assign({ ROW: row, SHEET: rows }, COLUMN_REFS)));
if (!_.isEmpty(rendered)) {
output.push(rendered);
}
});
if (options.reverse) {
output.reverse();
}
return format(output.join("\n\n"));
}
function parseCSV(file: File): Promise<Result> {
return new Promise((resolve, reject) => {
Papa.parse<string[]>(file, {
skipEmptyLines: true,
complete: function (results) {
resolve(results);
},
error: function (error) {
reject(error);
},
delimitersToGuess: [",", "\t", "|", ";", Papa.RECORD_SEP, Papa.UNIT_SEP, "^"]
});
});
}
async function parseXLSX(file: File): Promise<Result> {
const buffer = await readFile(file);
const sheet = XLSX.read(buffer, { type: "binary" });
const json = XLSX.utils.sheet_to_json<string[]>(sheet.Sheets[sheet.SheetNames[0]], {
header: 1,
blankrows: false,
rawNumbers: false
});
return { data: json };
}
async function parsePDF(file: File): Promise<Result> {
const buffer = await readFile(file);
const array = await pdf2array(buffer);
return { data: array };
}
function readFile(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
resolve(event.target.result as ArrayBuffer);
};
reader.onerror = (event) => {
reject(event);
};
reader.readAsArrayBuffer(file);
});
}

View File

@ -390,21 +390,27 @@ export interface Legend {
selected?: boolean;
}
export interface LedgerFile {
interface File {
type: "file";
name: string;
content: string;
versions: string[];
}
export interface LedgerDirectory {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface LedgerFile extends File {}
export interface Directory {
type: "directory";
name: string;
children: Array<LedgerDirectory | LedgerFile>;
children: Array<Directory | LedgerFile | SheetFile>;
}
export function buildLedgerTree(files: LedgerFile[]) {
const root: LedgerDirectory = {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface SheetFile extends File {}
export function buildDirectoryTree<T extends File>(files: T[]) {
const root: Directory = {
type: "directory",
name: "",
children: []
@ -423,7 +429,7 @@ export function buildLedgerTree(files: LedgerFile[]) {
};
current.children.push(found);
}
current = found as LedgerDirectory;
current = found as Directory;
}
current.children.push(file);
}
@ -438,6 +444,13 @@ export interface LedgerFileError {
message: string;
}
export interface SheetFileError {
line_from: number;
line_to: number;
error: string;
message: string;
}
export interface Node {
id: number;
name: string;
@ -478,6 +491,15 @@ export interface GoalSummary {
priority: number;
}
export interface SheetLineResult {
line: number;
result: string;
error: boolean;
underline?: boolean;
bold?: boolean;
align?: "left" | "right";
}
const tokenKey = "token";
const BACKGROUND = [
@ -486,6 +508,10 @@ const BACKGROUND = [
"/api/editor/file",
"/api/editor/files",
"/api/editor/file/delete_backups",
"/api/sheets/save",
"/api/sheets/file",
"/api/sheets/files",
"/api/sheets/file/delete_backups",
"/api/templates",
"/api/templates/upsert",
"/api/templates/delete",
@ -643,6 +669,25 @@ export function ajax(
options?: RequestInit
): Promise<{ file: LedgerFile }>;
export function ajax(route: "/api/sheets/files"): Promise<{
files: SheetFile[];
}>;
export function ajax(
route: "/api/sheets/save",
options?: RequestInit
): Promise<{ saved: boolean; file: SheetFile; message: string }>;
export function ajax(
route: "/api/sheets/file",
options?: RequestInit
): Promise<{ file: SheetFile }>;
export function ajax(
route: "/api/sheets/file/delete_backups",
options?: RequestInit
): Promise<{ file: SheetFile }>;
export function ajax(
route: "/api/price/delete",
options?: RequestInit

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { createEditor, editorState, moveToEnd, moveToLine, updateContent } from "$lib/editor";
import { insertTab } from "@codemirror/commands";
import { ajax, buildLedgerTree, type LedgerFile } from "$lib/utils";
import { ajax, buildDirectoryTree, type LedgerFile } from "$lib/utils";
import { redo, undo } from "@codemirror/commands";
import type { KeyBinding } from "@codemirror/view";
import * as toast from "bulma-toast";
@ -329,7 +329,7 @@
<FileTree
path=""
on:select={(e) => selectFile(e.detail)}
files={buildLedgerTree(_.values(filesMap))}
files={buildDirectoryTree(_.values(filesMap))}
selectedFileName={selectedFile?.name}
hasUnsavedChanges={$editorState.hasUnsavedChanges}
/>

View File

@ -9,7 +9,7 @@
updateContent as updatePreviewContent
} from "$lib/editor";
import Dropzone from "svelte-file-dropzone/Dropzone.svelte";
import { parse, asRows, render as renderJournal } from "$lib/sheet";
import { parse, asRows, render as renderJournal } from "$lib/spreadsheet";
import _ from "lodash";
import type { EditorView } from "codemirror";
import { onMount } from "svelte";

View File

@ -0,0 +1,67 @@
<script lang="ts">
import { goto } from "$app/navigation";
import FileModal from "$lib/components/FileModal.svelte";
import { ajax } from "$lib/utils";
import * as toast from "bulma-toast";
let modalOpen = false;
function openCreateModal() {
modalOpen = true;
}
async function createFile(destinationFile: string) {
destinationFile = destinationFile.trim() + ".paisa";
const { saved, message } = await ajax("/api/sheets/save", {
method: "POST",
body: JSON.stringify({ name: destinationFile, content: "", operation: "create" })
});
if (saved) {
toast.toast({
message: `Created <b><a href="/more/sheets/${encodeURIComponent(
destinationFile
)}">${destinationFile}</a></b>`,
type: "is-success",
duration: 5000
});
await goto(`/more/sheets/${encodeURIComponent(destinationFile)}`);
} else {
toast.toast({
message: `Failed to create ${destinationFile}. reason: ${message}`,
type: "is-danger",
duration: 10000
});
}
}
</script>
<FileModal
bind:open={modalOpen}
on:save={(e) => createFile(e.detail)}
label="Create"
placeholder="scratch"
help="Filename without any extension"
/>
<section class="section">
<div class="container is-fluid">
<div class="columns">
<div class="column is-6 mx-auto">
<div class="box px-3">
<div class="field">
<p class="control">
<button class="button is-link" on:click={(_e) => openCreateModal()}>
<span class="icon is-small">
<i class="fas fa-file-circle-plus" />
</span>
<span>Create</span>
</button>
</p>
<p class="mt-2 has-text-grey has-text-bold">Create your first sheet</p>
</div>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,10 @@
import { redirect } from "@sveltejs/kit";
import { ajax } from "$lib/utils";
import type { PageLoad } from "./$types";
export const load: PageLoad = async () => {
const { files } = await ajax("/api/sheets/files");
if (files.length > 0) {
redirect(307, `/more/sheets/${files[0].name}`);
}
};

View File

@ -0,0 +1,344 @@
<script lang="ts">
import { createEditor, sheetEditorState } from "$lib/sheet";
import { moveToEnd, moveToLine, updateContent } from "$lib/editor";
import { ajax, buildDirectoryTree, type SheetFile } from "$lib/utils";
import { redo, undo } from "@codemirror/commands";
import type { KeyBinding } from "@codemirror/view";
import * as toast from "bulma-toast";
import type { EditorView } from "codemirror";
import _ from "lodash";
import { onMount } from "svelte";
import { beforeNavigate, goto } from "$app/navigation";
import type { PageData } from "./$types";
import FileTree from "$lib/components/FileTree.svelte";
import FileModal from "$lib/components/FileModal.svelte";
import { page } from "$app/stores";
export let data: PageData;
let editorDom: Element;
let editor: EditorView;
let filesMap: Record<string, SheetFile> = {};
let selectedFile: SheetFile = null;
let selectedVersion: string = null;
let lineNumber = 0;
function command(fn: Function) {
return () => {
fn();
return true;
};
}
const keybindings: readonly KeyBinding[] = [
{
key: "Ctrl-s",
run: command(save),
preventDefault: true
}
];
let cancelled = false;
beforeNavigate(async ({ cancel }) => {
if ($sheetEditorState.hasUnsavedChanges) {
const confirmed = confirm("You have unsaved changes. Are you sure you want to leave?");
if (!confirmed) {
cancel();
cancelled = true;
} else {
$sheetEditorState = _.assign({}, $sheetEditorState, { hasUnsavedChanges: false });
}
}
});
async function navigate(url: string) {
await goto(url, { noScroll: true });
if (cancelled) {
cancelled = false;
return false;
}
return true;
}
onMount(async () => {
loadFiles(data.name);
const line = _.toNumber($page.url.hash.substring(1));
if (_.isNumber(line)) {
lineNumber = line;
}
});
async function loadFiles(selectedFileName: string) {
let files;
({ files } = await ajax("/api/sheets/files"));
filesMap = _.fromPairs(_.map(files, (f) => [f.name, f]));
if (!_.isEmpty(files)) {
selectedFile = _.find(files, (f) => f.name == selectedFileName) || files[0];
}
}
async function selectFile(file: SheetFile) {
const success = await navigate(`/more/sheets/${encodeURIComponent(file.name)}`);
if (success) {
selectedFile = file;
}
}
async function revert(version: string) {
const { file } = await ajax("/api/sheets/file", {
method: "POST",
body: JSON.stringify({ name: version })
});
updateContent(editor, file.content);
}
async function deleteBackups() {
const { file } = await ajax("/api/sheets/file/delete_backups", {
method: "POST",
body: JSON.stringify({ name: selectedFile.name })
});
selectedFile.versions = file.versions;
}
async function save() {
const doc = editor.state.doc;
const { saved, file, message } = await ajax("/api/sheets/save", {
method: "POST",
body: JSON.stringify({ name: selectedFile.name, content: doc.toString() })
});
if (!saved) {
toast.toast({
message: `Failed to save ${selectedFile.name}. reason: ${message}`,
type: "is-danger",
duration: 10000
});
} else {
toast.toast({
message: `Saved ${selectedFile.name}`,
type: "is-success"
});
filesMap[file.name] = file;
selectedFile = file;
selectedVersion = null;
$sheetEditorState = _.assign({}, $sheetEditorState, { hasUnsavedChanges: false });
}
}
$: if (selectedFile) {
if (!editor || editor.state.doc.toString() != selectedFile.content) {
if (editor) {
editor.destroy();
}
editor = createEditor(selectedFile.content, editorDom, { keybindings });
if (lineNumber > 0) {
if (!editor.hasFocus) {
editor.focus();
}
moveToLine(editor, lineNumber, true);
lineNumber = 0;
} else {
moveToEnd(editor);
}
}
}
let modalOpen = false;
function openCreateModal() {
modalOpen = true;
}
async function createFile(destinationFile: string) {
destinationFile = destinationFile.trim() + ".paisa";
const { saved, message } = await ajax("/api/sheets/save", {
method: "POST",
body: JSON.stringify({ name: destinationFile, content: "", operation: "create" })
});
if (saved) {
toast.toast({
message: `Created <b><a href="/more/sheets/${encodeURIComponent(
destinationFile
)}">${destinationFile}</a></b>`,
type: "is-success",
duration: 5000
});
const success = await navigate(`/more/sheets/${encodeURIComponent(destinationFile)}`);
if (success) {
await loadFiles(destinationFile);
}
} else {
toast.toast({
message: `Failed to create ${destinationFile}. reason: ${message}`,
type: "is-danger",
duration: 10000
});
}
}
</script>
<FileModal
bind:open={modalOpen}
on:save={(e) => createFile(e.detail)}
label="Create"
placeholder="scratch"
help="Filename without any extension"
/>
<section class="section tab-editor max-h-screen" style="padding-bottom: 0 !important">
<div class="container is-fluid">
<div class="columuns">
<div class="column is-12 px-0 pt-0 mb-2">
<div class="box p-3 is-flex is-align-items-center overflow-x-auto" style="width: 100%">
<div class="field has-addons mb-0">
<p class="control">
<button
class="button is-small is-link invertable is-light"
disabled={$sheetEditorState.hasUnsavedChanges}
on:click={(_e) => openCreateModal()}
>
<span class="icon is-small">
<i class="fas fa-file-circle-plus" />
</span>
<span>Create</span>
</button>
</p>
</div>
<div class="field has-addons ml-5 mb-0">
<p class="control">
<button
class="button is-small"
disabled={$sheetEditorState.hasUnsavedChanges == false}
on:click={(_e) => save()}
>
<span class="icon is-small">
<i class="fas fa-floppy-disk" />
</span>
<span>Save</span>
</button>
</p>
<p class="control">
<button
class="button is-small"
disabled={$sheetEditorState.undoDepth == 0}
on:click={(_e) => undo(editor)}
>
<span class="icon is-small">
<i class="fas fa-arrow-left" />
</span>
<span>Undo</span>
</button>
</p>
<p class="control">
<button
class="button is-small"
disabled={$sheetEditorState.redoDepth == 0}
on:click={(_e) => redo(editor)}
>
<span>Redo</span>
<span class="icon is-small">
<i class="fas fa-arrow-right" />
</span>
</button>
</p>
</div>
{#if !_.isEmpty(selectedFile?.versions)}
<div class="field has-addons ml-5 mb-0">
<p class="control">
<button
class="button is-small"
disabled={!selectedVersion}
on:click={(_e) => revert(selectedVersion)}
>
<span class="icon is-small">
<i class="fas fa-clock-rotate-left" />
</span>
<span>Revert</span>
</button>
</p>
<div class="control">
<div class="select is-small">
<select bind:value={selectedVersion}>
{#each selectedFile.versions as version}
<option>{version}</option>
{/each}
</select>
</div>
</div>
<p class="control">
<button class="button is-small" on:click={(_e) => deleteBackups()}>
<span class="icon is-small">
<i class="fas fa-trash" />
</span>
</button>
</p>
</div>
{/if}
{#if $sheetEditorState.errors.length > 0}
<div class="control ml-5">
<a on:click={(_e) => moveToLine(editor, $sheetEditorState.errors[0].line_from)}
><span class="ml-1 tag invertable is-danger is-light"
>{$sheetEditorState.errors.length} error(s) found</span
></a
>
</div>
{/if}
</div>
</div>
</div>
<div class="columns">
<div class="column is-3-widescreen is-2-fullhd is-4">
<div class="box px-2 full-height overflow-y-auto">
<aside class="menu">
<FileTree
path=""
on:select={(e) => selectFile(e.detail)}
files={buildDirectoryTree(_.values(filesMap))}
selectedFileName={selectedFile?.name}
hasUnsavedChanges={$sheetEditorState.hasUnsavedChanges}
/>
</aside>
</div>
</div>
<div class="column is-9-widescreen is-10-fullhd is-8">
<div class="flex">
<div class="box box-r-none py-0 pr-1 mb-0 basis-[36rem]">
<div class="sheet-editor" bind:this={editorDom} />
</div>
<div
class="box box-l-none has-text-right sheet-result"
style="padding: 4px 0; width: 200px;"
>
{#each $sheetEditorState.results as result, i}
<div
class={i + 1 === $sheetEditorState.currentLine
? "has-background-grey-lightest has-text-grey-dark has-text-weight-bold"
: ""}
style="padding: 0 0.5rem"
>
<div
title={result.result}
class:underline={result.underline}
class:font-bold={result.bold}
class:text-left={result.align === "left"}
class="m-0 p-0 truncate {result.error ? 'has-text-danger' : ''}"
style="font-size: 0.928rem; line-height: 1.4"
>
&nbsp;{result.result}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
</section>

View File

@ -0,0 +1,7 @@
import type { PageLoad } from "./$types";
export const load = (async ({ params }) => {
return {
name: params.slug
};
}) satisfies PageLoad;

View File

@ -2,7 +2,7 @@ import { writable, derived, get } from "svelte/store";
import * as d3 from "d3";
import dayjs from "dayjs";
import type { AccountTfIdf, LedgerFileError } from "$lib/utils";
import type { AccountTfIdf, LedgerFileError, SheetFileError, SheetLineResult } from "$lib/utils";
import _ from "lodash";
export function now() {
@ -28,7 +28,28 @@ export const initialEditorState: EditorState = {
output: ""
};
interface SheetEditorState {
hasUnsavedChanges: boolean;
undoDepth: number;
redoDepth: number;
doc: string;
currentLine: number;
errors: SheetFileError[];
results: SheetLineResult[];
}
export const initialSheetEditorState: SheetEditorState = {
hasUnsavedChanges: false,
undoDepth: 0,
redoDepth: 0,
currentLine: 0,
doc: "",
errors: [],
results: []
};
export const editorState = writable(initialEditorState);
export const sheetEditorState = writable(initialSheetEditorState);
export const month = writable(now().format("YYYY-MM"));
export const year = writable<string>("");