sheets initial commit
This commit is contained in:
parent
8236a09fbd
commit
062bb8bf40
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
24
src/app.scss
24
src/app.scss
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -61,6 +61,6 @@ DateValue { "[" dateChar+ "]" }
|
|||
|
||||
@skip { whitespace }
|
||||
|
||||
@external propSource jsonHighlighting from "./highlight"
|
||||
@external propSource queryHighlighting from "./highlight"
|
||||
|
||||
@detectDelim
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
185
src/lib/sheet.ts
185
src/lib/sheet.ts
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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)))))))
|
|
@ -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
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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
|
|
@ -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);
|
||||
}
|
|
@ -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":"\"=\""}
|
||||
})
|
|
@ -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
|
|
@ -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));
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
|
@ -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}`);
|
||||
}
|
||||
};
|
|
@ -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"
|
||||
>
|
||||
{result.result}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1,7 @@
|
|||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
return {
|
||||
name: params.slug
|
||||
};
|
||||
}) satisfies PageLoad;
|
23
src/store.ts
23
src/store.ts
|
@ -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>("");
|
||||
|
|
Loading…
Reference in New Issue