(squash) init

feat: filters, settings, backups

fix: ts compile errors

feat: new dashboard, webp previews and settings

feat: use webp for pdfs

feat: use webp

fix: analyze resets old data

fix: switch to corsproxy

fix: switch to free cors

fix: max upload limit

fix: currency conversion

feat: transaction export

fix: currency conversion

feat: refactor settings actions

feat: new loader

feat: README + LICENSE

doc: update readme

doc: update readme

doc: update readme

doc: update screenshots

ci: bump prisma
This commit is contained in:
Vasily Zubarev
2025-03-13 00:30:47 +01:00
commit 0b98a2c307
153 changed files with 17271 additions and 0 deletions

57
lib/currency-scraper.ts Normal file
View File

@@ -0,0 +1,57 @@
import { format } from "date-fns"
type HistoricRate = {
currency: string
rate: number
inverse: number
}
export async function getCurrencyRate(currencyCodeFrom: string, currencyCodeTo: string, date: Date): Promise<number> {
const rates = await fetchHistoricalCurrencyRates(currencyCodeFrom, date)
if (!rates || rates.length === 0) {
console.log("Could not fetch currency rates", currencyCodeFrom, currencyCodeTo, date)
return 0
}
const rate = rates.find((rate) => rate.currency === currencyCodeTo)
if (!rate) {
console.log("Could not find currency rate", currencyCodeFrom, currencyCodeTo, date)
return 0
}
return rate.rate
}
export async function fetchHistoricalCurrencyRates(currency: string = "USD", date: Date): Promise<HistoricRate[]> {
const formattedDate = format(date, "yyyy-MM-dd")
const url = `https://corsproxy.io/?${encodeURIComponent(
`https://www.xe.com/currencytables/?from=${currency}&date=${formattedDate}`
)}`
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
}
const html = await response.text()
// Extract the JSON data from the __NEXT_DATA__ script tag
const scriptTagRegex = /<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/
const match = html.match(scriptTagRegex)
if (!match || !match[1]) {
throw new Error("Could not find currency data in the page")
}
const jsonData = JSON.parse(match[1])
const historicRates = jsonData.props.pageProps.historicRates as HistoricRate[]
console.log("Historic Rates for this date", historicRates)
return historicRates
}

11
lib/db.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ["query", "info", "warn", "error"] })
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
export const DATABASE_FILE = process.env.DATABASE_URL?.split(":").pop() || "db.sqlite"

36
lib/files.ts Normal file
View File

@@ -0,0 +1,36 @@
import { Transaction } from "@prisma/client"
import { randomUUID } from "crypto"
import path from "path"
export const FILE_ACCEPTED_MIMETYPES = "image/*,.pdf,.doc,.docx,.xls,.xlsx"
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
export const FILE_UNSORTED_UPLOAD_PATH = path.join(FILE_UPLOAD_PATH, "unsorted")
export const FILE_PREVIEWS_PATH = path.join(FILE_UPLOAD_PATH, "previews")
export async function getUnsortedFileUploadPath(filename: string) {
const fileUuid = randomUUID()
const fileExtension = path.extname(filename)
const storedFileName = `${fileUuid}${fileExtension}`
const filePath = path.join(FILE_UNSORTED_UPLOAD_PATH, storedFileName)
return { fileUuid, filePath }
}
export async function getTransactionFileUploadPath(filename: string, transaction: Transaction) {
const fileUuid = randomUUID()
const fileExtension = path.extname(filename)
const storedFileName = `${fileUuid}${fileExtension}`
const formattedPath = formatFilePath(storedFileName, transaction.issuedAt || new Date())
const filePath = path.join(FILE_UPLOAD_PATH, formattedPath)
return { fileUuid, filePath }
}
function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{name}{ext}") {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const ext = path.extname(filename)
const name = path.basename(filename, ext)
return format.replace("{YYYY}", String(year)).replace("{MM}", month).replace("{name}", name).replace("{ext}", ext)
}

50
lib/images.ts Normal file
View File

@@ -0,0 +1,50 @@
import { FILE_PREVIEWS_PATH } from "@/lib/files"
import { existsSync } from "fs"
import fs from "fs/promises"
import path from "path"
import sharp from "sharp"
const MAX_WIDTH = 1800
const MAX_HEIGHT = 1800
const QUALITY = 90
export async function resizeImage(
origFilePath: string,
maxWidth: number = MAX_WIDTH,
maxHeight: number = MAX_HEIGHT
): Promise<{ contentType: string; resizedPath: string }> {
try {
await fs.mkdir(FILE_PREVIEWS_PATH, { recursive: true })
const basename = path.basename(origFilePath, path.extname(origFilePath))
const outputPath = path.join(FILE_PREVIEWS_PATH, `${basename}.webp`)
if (existsSync(outputPath)) {
const metadata = await sharp(outputPath).metadata()
return {
contentType: `image/${metadata.format}`,
resizedPath: outputPath,
}
}
await sharp(origFilePath)
.rotate()
.resize(maxWidth, maxHeight, {
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality: QUALITY })
.toFile(outputPath)
return {
contentType: "image/webp",
resizedPath: outputPath,
}
} catch (error) {
console.error("Error resizing image:", error)
return {
contentType: "image/unknown",
resizedPath: origFilePath,
}
}
}

57
lib/pdf.ts Normal file
View File

@@ -0,0 +1,57 @@
import { existsSync } from "fs"
import fs from "fs/promises"
import path from "path"
import { fromPath } from "pdf2pic"
import { FILE_PREVIEWS_PATH } from "./files"
const MAX_PAGES = 10
const DPI = 150
const QUALITY = 90
const MAX_WIDTH = 1500
const MAX_HEIGHT = 1500
export async function pdfToImages(origFilePath: string): Promise<{ contentType: string; pages: string[] }> {
await fs.mkdir(FILE_PREVIEWS_PATH, { recursive: true })
const basename = path.basename(origFilePath, path.extname(origFilePath))
// Check if converted pages already exist
const existingPages: string[] = []
for (let i = 1; i <= MAX_PAGES; i++) {
const convertedFilePath = path.join(FILE_PREVIEWS_PATH, `${basename}.${i}.webp`)
if (existsSync(convertedFilePath)) {
existingPages.push(convertedFilePath)
} else {
break
}
}
if (existingPages.length > 0) {
return { contentType: "image/webp", pages: existingPages }
}
// If not — convert the file as store in previews folder
const pdf2picOptions = {
density: DPI,
saveFilename: basename,
savePath: FILE_PREVIEWS_PATH,
format: "webp",
quality: QUALITY,
width: MAX_WIDTH,
height: MAX_HEIGHT,
preserveAspectRatio: true,
}
try {
const convert = fromPath(origFilePath, pdf2picOptions)
const results = await convert.bulk(-1, { responseType: "image" }) // TODO: respect MAX_PAGES here too
const paths = results.filter((result) => result && result.path).map((result) => result.path) as string[]
return {
contentType: "image/webp",
pages: paths,
}
} catch (error) {
console.error("Error converting PDF to image:", error)
throw error
}
}

13
lib/stats.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Transaction } from "@prisma/client"
export function calcTotalPerCurrency(transactions: Transaction[]): Record<string, number> {
return transactions.reduce((acc, transaction) => {
if (transaction.convertedCurrencyCode) {
acc[transaction.convertedCurrencyCode] =
(acc[transaction.convertedCurrencyCode] || 0) + (transaction.convertedTotal || 0)
} else if (transaction.currencyCode) {
acc[transaction.currencyCode] = (acc[transaction.currencyCode] || 0) + (transaction.total || 0)
}
return acc
}, {} as Record<string, number>)
}

24
lib/utils.ts Normal file
View File

@@ -0,0 +1,24 @@
import { clsx, type ClassValue } from "clsx"
import slugify from "slugify"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(total: number, currency: string) {
return new Intl.NumberFormat("en", {
style: "currency",
currency: currency,
}).format(total / 100)
}
export function codeFromName(name: string, maxLength: number = 16) {
const code = slugify(name, {
replacement: "_",
lower: true,
strict: true,
trim: true,
})
return code.slice(0, maxLength)
}