Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
module Api
|
||||
class AuthController < BaseController
|
||||
rate_limit to: 20, within: 1.hour, only: :magic_link, with: -> { too_many }
|
||||
rate_limit to: 30, within: 15.minutes, only: :code, with: -> { too_many }
|
||||
|
||||
# POST /api/auth/magic_link { email, return_to }
|
||||
# Always the same answer, so it can't be used to find out who has an account.
|
||||
def magic_link
|
||||
email = params.require(:email).to_s
|
||||
unless MagicLink.throttled?(email)
|
||||
link = MagicLink.issue!(email:, return_to: params[:return_to])
|
||||
AuthMailer.magic_link(link, link.token, link.code).deliver_later
|
||||
end
|
||||
render json: { ok: true }
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
render json: { error: "invalid_email" }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
# POST /api/auth/code { email, code } -- the 6-digit code from the mail
|
||||
def code
|
||||
link = MagicLink.find_by_code(params.require(:email), params.require(:code))
|
||||
return render(json: { error: "invalid_code" }, status: :unprocessable_entity) unless link
|
||||
start_session_for(link.redeem!)
|
||||
render json: { user: Current.user.as_json, redirect: link.destination }
|
||||
end
|
||||
|
||||
# DELETE /api/auth/session
|
||||
def logout
|
||||
terminate_session
|
||||
render json: { ok: true }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def too_many = render(json: { error: "rate_limited" }, status: :too_many_requests)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
module Api
|
||||
class BaseController < ApplicationController
|
||||
# the app sends the CSRF token from the page's meta tag as X-CSRF-Token
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
rescue_from ActiveRecord::RecordNotFound do
|
||||
render json: { error: "not_found" }, status: :not_found
|
||||
end
|
||||
rescue_from ActiveRecord::RecordInvalid do |e|
|
||||
render json: { error: "invalid", messages: e.record.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
rescue_from ActionController::InvalidAuthenticityToken do
|
||||
render json: { error: "csrf" }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# a share link token, for visitors (with or without account) who got the link
|
||||
def share_token = request.headers["X-Share-Token"].presence
|
||||
|
||||
# identifies the lock holder: one browser tab
|
||||
def client_id = request.headers["X-Client-Id"].to_s[0, 64].presence || "anon"
|
||||
|
||||
def lock_holder = "#{Current.user&.id || 'anon'}:#{client_id}"
|
||||
|
||||
def holder_name = Current.user&.name || "Jemand mit dem Link"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
module Api
|
||||
# The edit lock of a server document: one browser tab edits at a time,
|
||||
# everyone else sees the document read-only until the lock is released or
|
||||
# expires (Document::LOCK_TTL without a heartbeat).
|
||||
class DocumentLocksController < BaseController
|
||||
before_action :load_document
|
||||
|
||||
# POST /api/documents/:id/lock -- take or refresh (heartbeat)
|
||||
def create
|
||||
return render(json: { lock: { mine: false, holder_name: nil } }) unless Document.role_at_least?(@role, :editor)
|
||||
mine = @doc.acquire_lock!(lock_holder, holder_name)
|
||||
render json: { lock: { mine:, holder_name: (@doc.lock_holder_name unless mine) } }
|
||||
end
|
||||
|
||||
# DELETE /api/documents/:id/lock
|
||||
def destroy
|
||||
@doc.release_lock!(lock_holder)
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_document
|
||||
@doc = Document.find(params[:document_id])
|
||||
@role = @doc.role_for(user: Current.user, token: share_token)
|
||||
raise ActiveRecord::RecordNotFound unless @role
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
module Api
|
||||
# Server documents. Listing and creating need an account; reading and
|
||||
# writing a single document also work with just a share link token.
|
||||
class DocumentsController < BaseController
|
||||
before_action :require_authentication, only: %i[index create]
|
||||
before_action :load_document, except: %i[index create]
|
||||
|
||||
# GET /api/documents -- owned + shared with me
|
||||
def index
|
||||
user = Current.user
|
||||
memberships = user.document_memberships.index_by(&:document_id)
|
||||
docs = user.accessible_documents.includes(:owner, :updated_by).order(content_updated_at: :desc)
|
||||
render json: {
|
||||
documents: docs.map { |d| d.meta_json(role: d.owner_id == user.id ? :owner : memberships[d.id]&.role) }
|
||||
}
|
||||
end
|
||||
|
||||
# POST /api/documents { title, content } -- also used to upload a local document
|
||||
def create
|
||||
doc = Current.user.owned_documents.create!(
|
||||
title: params.require(:title), content: content_param, updated_by: Current.user
|
||||
)
|
||||
render json: { document: doc.meta_json(role: :owner) }, status: :created
|
||||
end
|
||||
|
||||
# GET /api/documents/:id[?known_version=n]
|
||||
# With known_version: just the metadata when nothing changed since.
|
||||
def show
|
||||
touch_membership
|
||||
meta = @doc.meta_json(role: @role).merge(lock: @doc.lock_info(lock_holder))
|
||||
if params[:known_version].present? && params[:known_version].to_i == @doc.version
|
||||
render json: { document: meta, unchanged: true }
|
||||
else
|
||||
render json: { document: meta.merge(content: @doc.content) }
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH /api/documents/:id { title } and/or { content, base_version }
|
||||
def update
|
||||
return forbidden unless Document.role_at_least?(@role, :editor)
|
||||
if params.key?(:content)
|
||||
return render(json: { error: "locked", holder_name: @doc.lock_holder_name }, status: :locked) if @doc.locked_by_other?(lock_holder)
|
||||
@doc.with_lock do
|
||||
if params.require(:base_version).to_i != @doc.version
|
||||
return render(json: { error: "conflict", version: @doc.version }, status: :conflict)
|
||||
end
|
||||
@doc.update!(content: content_param, version: @doc.version + 1,
|
||||
content_updated_at: Time.current, updated_by: Current.user)
|
||||
end
|
||||
end
|
||||
@doc.update!(title: params[:title]) if params[:title].present?
|
||||
render json: { document: @doc.meta_json(role: @role) }
|
||||
end
|
||||
|
||||
# DELETE /api/documents/:id -- the owner deletes it; anyone else leaves it
|
||||
def destroy
|
||||
if @role == :owner
|
||||
@doc.destroy!
|
||||
elsif Current.user
|
||||
@doc.document_memberships.where(user: Current.user).delete_all
|
||||
else
|
||||
return forbidden
|
||||
end
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_document
|
||||
@doc = Document.find(params[:id])
|
||||
@role = @doc.role_for(user: Current.user, token: share_token)
|
||||
raise ActiveRecord::RecordNotFound unless @role # 404, not 403: don't confirm it exists
|
||||
end
|
||||
|
||||
def content_param
|
||||
c = params.require(:content)
|
||||
c.is_a?(String) ? c : c.to_unsafe_h.to_json
|
||||
end
|
||||
|
||||
def touch_membership
|
||||
return unless Current.user
|
||||
@doc.document_memberships.where(user: Current.user).update_all(last_opened_at: Time.current)
|
||||
end
|
||||
|
||||
def forbidden = render(json: { error: "forbidden" }, status: :forbidden)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
module Api
|
||||
# POST /api/documents/:document_id/join { token }
|
||||
# Redeems a share link. Signed in: the document is added to the account
|
||||
# (a membership). Not signed in: only answers what the token grants -- the
|
||||
# browser keeps the token and sends it along (X-Share-Token).
|
||||
class JoinsController < BaseController
|
||||
def create
|
||||
doc = Document.find(params[:document_id])
|
||||
link = doc.share_links.active.find_by(token: params.require(:token))
|
||||
raise ActiveRecord::RecordNotFound unless link
|
||||
if Current.user
|
||||
DocumentMembership.grant!(document: doc, user: Current.user, role: link.role, source: :link, share_link: link)
|
||||
end
|
||||
render json: { document: doc.meta_json(role: doc.role_for(user: Current.user, token: link.token)) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
module Api
|
||||
class MeController < BaseController
|
||||
before_action :require_authentication, only: :update
|
||||
|
||||
# GET /api/me
|
||||
def show
|
||||
render json: { user: Current.user&.as_json }
|
||||
end
|
||||
|
||||
# PATCH /api/me { display_name }
|
||||
def update
|
||||
Current.user.update!(params.permit(:display_name))
|
||||
render json: { user: Current.user.as_json }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
module Api
|
||||
# Everything about who has access to a document. Owner only.
|
||||
class SharesController < BaseController
|
||||
before_action :require_authentication
|
||||
before_action :load_owned_document
|
||||
|
||||
# GET /api/documents/:document_id/share
|
||||
def show
|
||||
render json: share_json
|
||||
end
|
||||
|
||||
# POST /api/documents/:document_id/share/links { role }
|
||||
# One active link per role: creating one replaces (revokes) the previous.
|
||||
def create_link
|
||||
role = params.require(:role)
|
||||
return render(json: { error: "invalid_role" }, status: :unprocessable_entity) unless ShareLink.roles.key?(role)
|
||||
@doc.transaction do
|
||||
@doc.share_links.active.where(role:).find_each(&:revoke!)
|
||||
@doc.share_links.create!(role:, created_by: Current.user)
|
||||
end
|
||||
render json: share_json
|
||||
end
|
||||
|
||||
# DELETE /api/documents/:document_id/share/links/:id
|
||||
def revoke_link
|
||||
@doc.share_links.active.find(params[:id]).revoke!
|
||||
render json: share_json
|
||||
end
|
||||
|
||||
# POST /api/documents/:document_id/share/invitations { email, role }
|
||||
def invite
|
||||
role = params.require(:role)
|
||||
return render(json: { error: "invalid_role" }, status: :unprocessable_entity) unless MagicLink.roles.key?(role)
|
||||
link = MagicLink.issue!(email: params.require(:email), purpose: :invitation, document: @doc, role:, invited_by: Current.user)
|
||||
if link.email == Current.user.email
|
||||
link.destroy!
|
||||
return render(json: { error: "self_invite" }, status: :unprocessable_entity)
|
||||
end
|
||||
AuthMailer.invitation(link, link.token).deliver_later
|
||||
render json: share_json
|
||||
end
|
||||
|
||||
# PATCH /api/documents/:document_id/share/members/:user_id { role }
|
||||
def update_member
|
||||
m = @doc.document_memberships.find_by!(user_id: params[:user_id])
|
||||
m.update!(role: params.require(:role))
|
||||
render json: share_json
|
||||
end
|
||||
|
||||
# DELETE /api/documents/:document_id/share/members/:user_id
|
||||
def remove_member
|
||||
@doc.document_memberships.where(user_id: params[:user_id]).delete_all
|
||||
render json: share_json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_owned_document
|
||||
@doc = Current.user.owned_documents.find(params[:document_id])
|
||||
end
|
||||
|
||||
def share_json
|
||||
{
|
||||
links: @doc.share_links.active.order(:created_at).map(&:as_json),
|
||||
members: @doc.document_memberships.includes(:user).order(:created_at).map do |m|
|
||||
{ user_id: m.user_id, name: m.user.name, email: m.user.email, role: m.role, source: m.source }
|
||||
end,
|
||||
invitations: MagicLink.usable.invitation.where(document: @doc).order(:created_at).map do |i|
|
||||
{ email: i.email, role: i.role, expires_at: i.expires_at.iso8601 }
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Authentication
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
# The page behind the link in a sign-in / invitation mail. Opening the link
|
||||
# (GET) only shows a button; the sign-in happens on POST. Mail scanners
|
||||
# fetch links on their own and would otherwise use up the one-time token.
|
||||
class AuthController < ApplicationController
|
||||
layout "auth"
|
||||
rate_limit to: 30, within: 15.minutes, only: :create
|
||||
|
||||
def show
|
||||
@token = params[:token].to_s
|
||||
@link = MagicLink.find_by_token(@token)
|
||||
render :invalid, status: :not_found unless @link
|
||||
end
|
||||
|
||||
def create
|
||||
link = MagicLink.find_by_token(params[:token])
|
||||
return render(:invalid, status: :not_found) unless link
|
||||
start_session_for(link.redeem!)
|
||||
redirect_to link.destination
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render :invalid, status: :not_found
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# Cookie session (signed session id), resumed on every request. Everything
|
||||
# is usable without signing in; controllers call `require_authentication`
|
||||
# where an account is needed.
|
||||
module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :resume_session
|
||||
helper_method :signed_in?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def signed_in? = Current.user.present?
|
||||
|
||||
def resume_session
|
||||
Current.session = Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||
touch_session if Current.session
|
||||
end
|
||||
|
||||
def touch_session
|
||||
s = Current.session
|
||||
return if s.last_active_at && s.last_active_at > 1.hour.ago
|
||||
s.update_columns(last_active_at: Time.current)
|
||||
s.user.update_columns(last_seen_at: Time.current)
|
||||
end
|
||||
|
||||
def require_authentication
|
||||
render json: { error: "unauthenticated" }, status: :unauthorized unless signed_in?
|
||||
end
|
||||
|
||||
def start_session_for(user)
|
||||
reset_session
|
||||
Current.session = user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, last_active_at: Time.current)
|
||||
cookies.signed.permanent[:session_id] = { value: Current.session.id, httponly: true, same_site: :lax, secure: Rails.env.production? }
|
||||
end
|
||||
|
||||
def terminate_session
|
||||
Current.session&.destroy
|
||||
Current.session = nil
|
||||
cookies.delete(:session_id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
# Serves the Vue app for every client-side route (/, /d/<id>). The signed-in
|
||||
# user is embedded so the app knows it before its first render.
|
||||
class SpaController < ApplicationController
|
||||
layout false
|
||||
|
||||
def show
|
||||
@boot = { user: Current.user&.as_json }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import AppToolbar from './components/AppToolbar.vue';
|
||||
import FormulaBar from './components/FormulaBar.vue';
|
||||
import SheetCanvas from './components/SheetCanvas.vue';
|
||||
import SheetTabs from './components/SheetTabs.vue';
|
||||
import StatusBar from './components/StatusBar.vue';
|
||||
import SettingsPanel from './components/SettingsPanel.vue';
|
||||
import DependencyGraphView from './components/DependencyGraphView.vue';
|
||||
import { useToasts } from './composables/useToasts';
|
||||
import { useDocument } from './store/document';
|
||||
|
||||
const toolbar = ref<InstanceType<typeof AppToolbar> | null>(null);
|
||||
const { toasts, dismiss } = useToasts();
|
||||
const store = useDocument();
|
||||
|
||||
function onCanvasFiles(files: FileList) {
|
||||
if (files[0]) toolbar.value?.handleFile(files[0]);
|
||||
}
|
||||
|
||||
/** Fallback Strg/Cmd+Z/Y when no table grid has focus (e.g. an empty canvas
|
||||
* click) -- TableGrid.vue handles it directly (and correctly defers to a
|
||||
* cell editor's own native undo) whenever a table *does* have focus. Skips
|
||||
* entirely while focus sits in any other text field (rename-sheet, CSV
|
||||
* import, a colour input, ...), so their own native undo keeps working. */
|
||||
function isTextEditable(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || (el as HTMLElement).isContentEditable;
|
||||
}
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k !== 'z' && k !== 'y') return;
|
||||
if (isTextEditable(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
if (k === 'y' || (k === 'z' && e.shiftKey)) store.redo();
|
||||
else store.undo();
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<AppToolbar ref="toolbar" />
|
||||
<FormulaBar />
|
||||
|
||||
<main class="workspace">
|
||||
<SheetCanvas @files="onCanvasFiles" />
|
||||
<SettingsPanel />
|
||||
<DependencyGraphView />
|
||||
</main>
|
||||
|
||||
<div class="bottom-bar">
|
||||
<SheetTabs />
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
<div class="toasts">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="toast"
|
||||
:class="t.kind"
|
||||
@click="dismiss(t.id)"
|
||||
>
|
||||
{{ t.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
.bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--surface-2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.workspace {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 1200;
|
||||
}
|
||||
.toast {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
max-width: 320px;
|
||||
}
|
||||
.toast.success {
|
||||
border-color: var(--series-2);
|
||||
}
|
||||
.toast.error {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,944 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import CustomFunctionsModal from './CustomFunctionsModal.vue';
|
||||
import NamedRangesModal from './NamedRangesModal.vue';
|
||||
import MacrosModal from './MacrosModal.vue';
|
||||
import DocumentsModal from './DocumentsModal.vue';
|
||||
import LoginModal from './LoginModal.vue';
|
||||
import ShareModal from './ShareModal.vue';
|
||||
import UploadModal from './UploadModal.vue';
|
||||
import { useLibrary } from '../store/library';
|
||||
import { useSession } from '../store/session';
|
||||
import { parseDocument } from '../io/json';
|
||||
import TheLogo from './TheLogo.vue';
|
||||
import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
|
||||
import { WICOM, WICOM_SOCIAL } from '../config/brand';
|
||||
import {
|
||||
exportJson,
|
||||
exportNum,
|
||||
readNumFile,
|
||||
exportActiveTableCsv,
|
||||
exportXlsx,
|
||||
importJsonReplace,
|
||||
importJsonAppend,
|
||||
importCsv,
|
||||
importXlsxFile,
|
||||
readAsText,
|
||||
readAsArrayBuffer,
|
||||
extensionOf,
|
||||
type CsvImportConfig,
|
||||
} from '../io/operations';
|
||||
import type { Delimiter } from '../io/csv';
|
||||
|
||||
const store = useDocument();
|
||||
const library = useLibrary();
|
||||
const session = useSession();
|
||||
const { mode: themeMode, cycle: cycleTheme } = useTheme();
|
||||
const toasts = useToasts();
|
||||
|
||||
const fileMenuOpen = ref(false);
|
||||
|
||||
// ---- wordmark only when there's room ----------------------------------
|
||||
// "numolus" + "wicom" next to the logo are shown only while the whole
|
||||
// toolbar still fits on one line with them; otherwise just the logo. Measured
|
||||
// rather than a fixed breakpoint, since what fits depends on the toolbar's
|
||||
// actual contents. The wordmark's own width is remembered from the last time
|
||||
// it was visible, so hiding it can't make it look like it would fit again
|
||||
// (no flip-flopping at the threshold).
|
||||
const toolbarEl = ref<HTMLElement | null>(null);
|
||||
const brandExtra = ref<HTMLElement | null>(null);
|
||||
const showWordmark = ref(true);
|
||||
let wordmarkWidth = 0;
|
||||
|
||||
function fitWordmark() {
|
||||
const bar = toolbarEl.value;
|
||||
if (!bar) return;
|
||||
const cs = getComputedStyle(bar);
|
||||
const avail = bar.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
const gap = parseFloat(cs.columnGap) || 0;
|
||||
let need = 0;
|
||||
let items = 0;
|
||||
for (const child of Array.from(bar.children) as HTMLElement[]) {
|
||||
if (getComputedStyle(child).display === 'none') continue;
|
||||
items++;
|
||||
if (!child.classList.contains('spacer')) need += child.getBoundingClientRect().width;
|
||||
}
|
||||
need += gap * Math.max(0, items - 1);
|
||||
if (showWordmark.value && brandExtra.value) {
|
||||
// plus the gap to the logo, which goes away with it
|
||||
const brandGap = parseFloat(getComputedStyle(brandExtra.value.parentElement!).columnGap) || 0;
|
||||
wordmarkWidth = brandExtra.value.getBoundingClientRect().width + brandGap;
|
||||
} else {
|
||||
need += wordmarkWidth;
|
||||
}
|
||||
showWordmark.value = need <= avail + 0.5;
|
||||
}
|
||||
|
||||
let resizeObs: ResizeObserver | null = null;
|
||||
onMounted(() => {
|
||||
resizeObs = new ResizeObserver(() => fitWordmark());
|
||||
if (toolbarEl.value) resizeObs.observe(toolbarEl.value);
|
||||
fitWordmark();
|
||||
// web fonts arriving late change the measured widths
|
||||
void document.fonts?.ready.then(fitWordmark);
|
||||
});
|
||||
onBeforeUnmount(() => resizeObs?.disconnect());
|
||||
const fileMenuWrap = ref<HTMLElement | null>(null);
|
||||
|
||||
// Close the Datei menu on Escape or on a press anywhere outside it. Capture
|
||||
// phase, so canvas/grid handlers that stop propagation can't swallow it.
|
||||
function onOutsidePointer(e: PointerEvent) {
|
||||
if (!fileMenuWrap.value?.contains(e.target as Node)) fileMenuOpen.value = false;
|
||||
}
|
||||
function onMenuKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') fileMenuOpen.value = false;
|
||||
}
|
||||
function detachMenuListeners() {
|
||||
document.removeEventListener('pointerdown', onOutsidePointer, true);
|
||||
document.removeEventListener('keydown', onMenuKey, true);
|
||||
}
|
||||
watch(fileMenuOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener('pointerdown', onOutsidePointer, true);
|
||||
document.addEventListener('keydown', onMenuKey, true);
|
||||
} else {
|
||||
detachMenuListeners();
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(detachMenuListeners);
|
||||
const aboutOpen = ref(false);
|
||||
const functionsOpen = ref(false);
|
||||
const namedRangesOpen = ref(false);
|
||||
const macrosOpen = ref(false);
|
||||
const documentsOpen = ref(false);
|
||||
const shareOpen = ref(false);
|
||||
const uploadOpen = ref(false);
|
||||
const loginOpen = ref(false);
|
||||
const loginReason = ref<string | undefined>();
|
||||
const accountMenuOpen = ref(false);
|
||||
|
||||
function openLogin(reason?: string) {
|
||||
loginReason.value = reason;
|
||||
documentsOpen.value = false;
|
||||
shareOpen.value = false;
|
||||
loginOpen.value = true;
|
||||
}
|
||||
|
||||
// after signing in: offer once (per account, per browser) to move this
|
||||
// browser's documents into the account
|
||||
onMounted(() => {
|
||||
const user = session.state.user;
|
||||
if (!user || !library.state.docs.length) return;
|
||||
const key = `numolus:upload-asked:${user.id}`;
|
||||
try {
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, '1');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
uploadOpen.value = true;
|
||||
});
|
||||
|
||||
const statusNote = computed(() => {
|
||||
if (library.state.syncState === 'offline') return 'Offline – wird später gespeichert';
|
||||
if (library.state.syncState === 'saving') return 'Speichert …';
|
||||
return '';
|
||||
});
|
||||
|
||||
// ---- document title (inline rename) ------------------------------------
|
||||
const titleDraft = ref(library.currentTitle.value);
|
||||
watch(library.currentTitle, (t) => (titleDraft.value = t));
|
||||
function commitTitle() {
|
||||
const ref = library.state.current;
|
||||
const t = titleDraft.value.trim();
|
||||
if (!ref || !t) titleDraft.value = library.currentTitle.value;
|
||||
else if (t !== library.currentTitle.value) void library.rename(ref, t);
|
||||
}
|
||||
const busy = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const addr = `${WICOM.address.street}, ${WICOM.address.city}, ${WICOM.address.country}`;
|
||||
|
||||
const themeLabel = computed(
|
||||
() => ({ system: 'Auto', light: 'Hell', dark: 'Dunkel' })[themeMode.value],
|
||||
);
|
||||
const themeIcon = computed(
|
||||
() => ({ system: 'auto', light: 'sun', dark: 'moon' })[themeMode.value] as ToolbarIconName,
|
||||
);
|
||||
|
||||
// ---- insert objects ------------------------------------------------
|
||||
function insertTable() {
|
||||
store.addTable(store.doc.activeSheetId);
|
||||
toasts.info('Tabelle eingefügt.');
|
||||
}
|
||||
function insertChart() {
|
||||
const id = store.addChart(store.doc.activeSheetId);
|
||||
if (!id) toasts.error('Diagramm konnte nicht eingefügt werden.');
|
||||
}
|
||||
function insertText() {
|
||||
store.addText(store.doc.activeSheetId);
|
||||
toasts.info('Textfeld eingefügt.');
|
||||
}
|
||||
function insertButton() {
|
||||
store.addButton(store.doc.activeSheetId);
|
||||
toasts.info('Button eingefügt.');
|
||||
}
|
||||
|
||||
// ---- CSV export dialog -------------------------------------------
|
||||
const csvExportOpen = ref(false);
|
||||
const csvExport = ref<{ delimiter: Delimiter; bom: boolean; crlf: boolean }>({
|
||||
delimiter: ',',
|
||||
bom: false,
|
||||
crlf: true,
|
||||
});
|
||||
function openCsvExport() {
|
||||
csvExport.value.delimiter = store.doc.csvDelimiter; // start from the document default
|
||||
csvExportOpen.value = true;
|
||||
}
|
||||
function runCsvExport() {
|
||||
try {
|
||||
exportActiveTableCsv(store, csvExport.value);
|
||||
csvExportOpen.value = false;
|
||||
toasts.success('CSV exportiert.');
|
||||
} catch (err) {
|
||||
toasts.error((err as Error).message);
|
||||
}
|
||||
}
|
||||
async function runNumExport() {
|
||||
try {
|
||||
await exportNum(store, library.currentTitle.value);
|
||||
toasts.success('Dokument als .num gespeichert.');
|
||||
} catch (err) {
|
||||
toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runJsonExport() {
|
||||
exportJson(store, library.currentTitle.value);
|
||||
toasts.success('Dokument als JSON exportiert.');
|
||||
}
|
||||
async function runXlsxExport() {
|
||||
busy.value = true;
|
||||
try {
|
||||
await exportXlsx(store, library.currentTitle.value);
|
||||
toasts.success('Arbeitsmappe als XLSX exportiert.');
|
||||
} catch (err) {
|
||||
toasts.error(`XLSX-Export fehlgeschlagen: ${(err as Error).message}`);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
function exportSelectedChartPng() {
|
||||
const o = store.activeObject.value;
|
||||
if (o?.kind !== 'chart') {
|
||||
toasts.error('Kein Diagramm ausgewählt — Diagramm anklicken, dann exportieren.');
|
||||
return;
|
||||
}
|
||||
// ChartView listens for this via a window event keyed by object id
|
||||
window.dispatchEvent(new CustomEvent('export-chart-png', { detail: o.id }));
|
||||
}
|
||||
|
||||
// ---- import -----------------------------------------------------
|
||||
type ImportCtx =
|
||||
| { kind: 'json'; text: string; fileName: string }
|
||||
| ({ kind: 'csv'; text: string; fileName: string } & CsvImportConfig)
|
||||
| { kind: 'xlsx'; buffer: ArrayBuffer; fileName: string };
|
||||
|
||||
const importCtx = ref<ImportCtx | null>(null);
|
||||
const importOpen = ref(false);
|
||||
|
||||
function pickFile() {
|
||||
fileMenuOpen.value = false;
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onFileChosen(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (file) await handleFile(file);
|
||||
}
|
||||
|
||||
/** Also called from the canvas drag & drop. */
|
||||
async function handleFile(file: File) {
|
||||
const ext = extensionOf(file.name);
|
||||
try {
|
||||
if (ext === 'json') {
|
||||
importCtx.value = { kind: 'json', text: await readAsText(file), fileName: file.name };
|
||||
} else if (ext === 'num') {
|
||||
// a zipped JSON document -- unpacked, then the same replace/append choice
|
||||
importCtx.value = { kind: 'json', text: await readNumFile(await readAsArrayBuffer(file)), fileName: file.name };
|
||||
} else if (ext === 'csv') {
|
||||
importCtx.value = {
|
||||
kind: 'csv',
|
||||
text: await readAsText(file),
|
||||
fileName: file.name,
|
||||
mode: 'new-table',
|
||||
delimiter: 'auto',
|
||||
detectNumbers: true,
|
||||
name: file.name.replace(/\.csv$/i, ''),
|
||||
};
|
||||
} else if (ext === 'xlsx') {
|
||||
importCtx.value = {
|
||||
kind: 'xlsx',
|
||||
buffer: await readAsArrayBuffer(file),
|
||||
fileName: file.name,
|
||||
};
|
||||
} else {
|
||||
toasts.error(`Nicht unterstützter Dateityp: .${ext}`);
|
||||
return;
|
||||
}
|
||||
importOpen.value = true;
|
||||
} catch (err) {
|
||||
toasts.error((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ handleFile });
|
||||
|
||||
async function doImport(action: 'new' | 'replace' | 'append' | 'confirm') {
|
||||
const ctx = importCtx.value;
|
||||
if (!ctx) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
if (ctx.kind === 'json') {
|
||||
if (action === 'new') {
|
||||
await library.create(parseDocument(ctx.text), ctx.fileName.replace(/\.(num|json)$/i, ''));
|
||||
} else if (action === 'replace') {
|
||||
if (!confirm('Aktuelles Dokument vollständig ersetzen?')) return;
|
||||
importJsonReplace(store, ctx.text);
|
||||
} else {
|
||||
importJsonAppend(store, ctx.text);
|
||||
}
|
||||
} else if (ctx.kind === 'csv') {
|
||||
await importCsv(store, ctx.text, {
|
||||
mode: ctx.mode,
|
||||
delimiter: ctx.delimiter,
|
||||
detectNumbers: ctx.detectNumbers,
|
||||
name: ctx.name,
|
||||
});
|
||||
} else {
|
||||
await importXlsxFile(store, ctx.buffer);
|
||||
}
|
||||
toasts.success('Import erfolgreich.');
|
||||
importOpen.value = false;
|
||||
importCtx.value = null;
|
||||
} catch (err) {
|
||||
toasts.error(`Import fehlgeschlagen: ${(err as Error).message}`);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onNew() {
|
||||
await library.createBlank();
|
||||
toasts.info('Neues Dokument angelegt.');
|
||||
}
|
||||
|
||||
async function onSample() {
|
||||
await library.createSample();
|
||||
toasts.info('Musterdokument als neues Dokument angelegt.');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header ref="toolbarEl" class="toolbar">
|
||||
<div class="brand" title="Numolus — wicom, Vaduz (FL)">
|
||||
<TheLogo class="brand-mark" />
|
||||
<span v-show="showWordmark" ref="brandExtra" class="brand-extra">
|
||||
<span class="wordmark">numolus</span>
|
||||
<button class="by" type="button" @click="aboutOpen = true">wicom</button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="titleDraft"
|
||||
class="doc-title"
|
||||
type="text"
|
||||
title="Dokumentname (klicken zum Umbenennen)"
|
||||
aria-label="Dokumentname"
|
||||
:size="Math.max(8, Math.min(28, titleDraft.length + 1))"
|
||||
:readonly="library.currentRole.value === 'viewer'"
|
||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
|
||||
@keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()"
|
||||
@blur="commitTitle()"
|
||||
/>
|
||||
<button
|
||||
class="gear"
|
||||
:class="{ on: store.ui.settingsOpen }"
|
||||
title="Einstellungen"
|
||||
aria-label="Einstellungen"
|
||||
@click="store.ui.settingsOpen = !store.ui.settingsOpen"
|
||||
>
|
||||
<ToolbarIcon name="settings" />
|
||||
</button>
|
||||
|
||||
<div class="tgroup">
|
||||
<button
|
||||
class="ibtn"
|
||||
title="Rückgängig (Strg/Cmd+Z)"
|
||||
aria-label="Rückgängig"
|
||||
:disabled="!store.canUndo.value"
|
||||
@click="store.undo()"
|
||||
>
|
||||
<ToolbarIcon name="undo" />
|
||||
</button>
|
||||
<button
|
||||
class="ibtn"
|
||||
title="Wiederholen (Strg/Cmd+Y)"
|
||||
aria-label="Wiederholen"
|
||||
:disabled="!store.canRedo.value"
|
||||
@click="store.redo()"
|
||||
>
|
||||
<ToolbarIcon name="redo" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tgroup accent">
|
||||
<button class="ibtn" title="Tabelle einfügen" aria-label="Tabelle einfügen" @click="insertTable">
|
||||
<ToolbarIcon name="table" />
|
||||
</button>
|
||||
<button class="ibtn" title="Diagramm einfügen" aria-label="Diagramm einfügen" @click="insertChart">
|
||||
<ToolbarIcon name="chart" />
|
||||
</button>
|
||||
<button class="ibtn" title="Text einfügen" aria-label="Text einfügen" @click="insertText">
|
||||
<ToolbarIcon name="text" />
|
||||
</button>
|
||||
<button class="ibtn" title="Button einfügen" aria-label="Button einfügen" @click="insertButton">
|
||||
<ToolbarIcon name="button" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tgroup accent">
|
||||
<button
|
||||
class="ibtn fx"
|
||||
title="Benutzerdefinierte Funktionen"
|
||||
aria-label="Benutzerdefinierte Funktionen"
|
||||
@click="functionsOpen = true"
|
||||
>
|
||||
<em>fx</em>
|
||||
</button>
|
||||
<button
|
||||
class="ibtn"
|
||||
title="Benannte Bereiche"
|
||||
aria-label="Benannte Bereiche"
|
||||
@click="namedRangesOpen = true"
|
||||
>
|
||||
<ToolbarIcon name="tag" />
|
||||
</button>
|
||||
<button
|
||||
class="ibtn"
|
||||
title="Makros"
|
||||
aria-label="Makros"
|
||||
@click="macrosOpen = true"
|
||||
>
|
||||
<ToolbarIcon name="macro" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span v-if="store.ui.readOnly" class="ro-badge" :title="store.ui.readOnly">
|
||||
{{ library.state.lockedBy ? `${library.state.lockedBy} bearbeitet` : 'Nur Lesen' }}
|
||||
</span>
|
||||
<span v-else-if="statusNote" class="status-note">{{ statusNote }}</span>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<button class="btn share" title="Dokument teilen" @click="shareOpen = true">Teilen</button>
|
||||
|
||||
<div ref="fileMenuWrap" class="menu-wrap">
|
||||
<button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button>
|
||||
<ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false">
|
||||
<li><button @click="documentsOpen = true">Dokumente …</button></li>
|
||||
<li><button @click="onNew">Neues Dokument</button></li>
|
||||
<li><button @click="onSample">Muster · Umsatzanalyse</button></li>
|
||||
<li class="sep" />
|
||||
<li><button @click="pickFile">Öffnen / Importieren … (.num .json .csv .xlsx)</button></li>
|
||||
<li><button @click="runNumExport">Speichern · Dokument (.num)</button></li>
|
||||
<li class="sep" />
|
||||
<li><button @click="runJsonExport">Export · Dokument (.json)</button></li>
|
||||
<li><button @click="openCsvExport">Export · aktive Tabelle (.csv) …</button></li>
|
||||
<li><button @click="runXlsxExport">Export · Arbeitsmappe (.xlsx)</button></li>
|
||||
<li><button @click="exportSelectedChartPng">Export · gewähltes Diagramm (.png)</button></li>
|
||||
<li class="sep" />
|
||||
<li><button @click="aboutOpen = true">Über Numolus …</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="ibtn"
|
||||
:title="`Theme: ${themeLabel} (klicken zum Wechseln)`"
|
||||
:aria-label="`Theme: ${themeLabel}`"
|
||||
@click="cycleTheme"
|
||||
>
|
||||
<ToolbarIcon :name="themeIcon" />
|
||||
</button>
|
||||
|
||||
<div class="menu-wrap">
|
||||
<button
|
||||
v-if="session.state.user"
|
||||
class="btn account"
|
||||
:title="session.state.user.email"
|
||||
@click="accountMenuOpen = !accountMenuOpen"
|
||||
@blur="accountMenuOpen = false"
|
||||
>
|
||||
<span class="avatar" :style="{ background: session.state.user.color }">{{ session.state.user.name.slice(0, 1).toUpperCase() }}</span>
|
||||
{{ session.state.user.name }} ▾
|
||||
</button>
|
||||
<button v-else class="btn" @click="openLogin()">Anmelden</button>
|
||||
<ul v-if="accountMenuOpen" class="menu" @mousedown.prevent>
|
||||
<li class="menu-note">{{ session.state.user?.email }}</li>
|
||||
<li><button @click="accountMenuOpen = false; documentsOpen = true">Dokumente …</button></li>
|
||||
<li class="sep" />
|
||||
<li><button @click="session.logout()">Abmelden</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" accept=".num,.json,.csv,.xlsx" hidden @change="onFileChosen" />
|
||||
<span v-if="busy" class="busy">Verarbeite …</span>
|
||||
</header>
|
||||
|
||||
<!-- CSV export options -->
|
||||
<BaseModal :open="csvExportOpen" title="CSV exportieren" @close="csvExportOpen = false">
|
||||
<label class="row">
|
||||
Trennzeichen
|
||||
<select v-model="csvExport.delimiter">
|
||||
<option value=",">Komma ,</option>
|
||||
<option value=";">Semikolon ;</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row"><input v-model="csvExport.crlf" type="checkbox" /> Zeilenende CRLF (\r\n)</label>
|
||||
<label class="row"><input v-model="csvExport.bom" type="checkbox" /> UTF-8 BOM (für Excel)</label>
|
||||
<template #footer>
|
||||
<button @click="csvExportOpen = false">Abbrechen</button>
|
||||
<button class="primary" @click="runCsvExport">Exportieren</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<!-- Import dialog -->
|
||||
<BaseModal
|
||||
:open="importOpen"
|
||||
:title="`Import · ${importCtx?.fileName ?? ''}`"
|
||||
@close="importOpen = false"
|
||||
>
|
||||
<template v-if="importCtx?.kind === 'json'">
|
||||
<p>Dokument erkannt. Als eigenes Dokument öffnen oder in das aktuelle übernehmen?</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="importCtx?.kind === 'csv'">
|
||||
<label class="row">
|
||||
Ziel
|
||||
<select v-model="importCtx.mode">
|
||||
<option value="new-table">Als neue Tabelle (aktuelles Blatt)</option>
|
||||
<option value="replace-active">Aktive Tabelle ab A1 ersetzen</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="importCtx.mode === 'new-table'" class="row">
|
||||
Name <input v-model="importCtx.name" type="text" />
|
||||
</label>
|
||||
<label class="row">
|
||||
Trennzeichen
|
||||
<select v-model="importCtx.delimiter">
|
||||
<option value="auto">Automatisch erkennen</option>
|
||||
<option value=",">Komma ,</option>
|
||||
<option value=";">Semikolon ;</option>
|
||||
<option :value="'\t'">Tab</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row">
|
||||
<input v-model="importCtx.detectNumbers" type="checkbox" />
|
||||
Zahlen-/Locale-Erkennung (1.234,56 ↔ 1,234.56)
|
||||
</label>
|
||||
<p class="hint">Werte mit führendem „=" werden als Formel übernommen.</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="importCtx?.kind === 'xlsx'">
|
||||
<p>Arbeitsmappe wird als neue Blätter angehängt – je Worksheet ein Blatt mit einer Tabelle.</p>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<button @click="importOpen = false">Abbrechen</button>
|
||||
<template v-if="importCtx?.kind === 'json'">
|
||||
<button @click="doImport('append')">Anhängen</button>
|
||||
<button @click="doImport('replace')">Ersetzen</button>
|
||||
<button class="primary" @click="doImport('new')">Als neues Dokument</button>
|
||||
</template>
|
||||
<button v-else class="primary" :disabled="busy" @click="doImport('confirm')">Importieren</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<!-- Benutzerdefinierte Funktionen -->
|
||||
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
|
||||
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
|
||||
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
|
||||
<DocumentsModal :open="documentsOpen" @close="documentsOpen = false" @login="openLogin()" />
|
||||
<ShareModal
|
||||
:open="shareOpen"
|
||||
@close="shareOpen = false"
|
||||
@login="openLogin('Zum Teilen brauchst du ein Konto.')"
|
||||
/>
|
||||
<LoginModal :open="loginOpen" :reason="loginReason" @close="loginOpen = false" />
|
||||
<UploadModal :open="uploadOpen" @close="uploadOpen = false" />
|
||||
|
||||
<!-- Über / Impressum -->
|
||||
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
|
||||
<p class="about-lead">
|
||||
<strong>Numolus</strong> — browserbasierte Tabellenkalkulation nach dem Vorbild von
|
||||
Apple Numbers, mit selbst geschriebener Formel-Engine.
|
||||
</p>
|
||||
|
||||
<dl class="about">
|
||||
<dt>Herausgeber</dt>
|
||||
<dd>
|
||||
<a :href="WICOM.url" target="_blank" rel="noopener noreferrer">{{ WICOM.name }}</a>
|
||||
— {{ WICOM.descriptor }}<br />
|
||||
<span class="dim">„{{ WICOM.tagline }}" · seit {{ WICOM.since }}</span>
|
||||
</dd>
|
||||
|
||||
<dt>Anschrift</dt>
|
||||
<dd>{{ addr }}</dd>
|
||||
|
||||
<dt>Kontakt</dt>
|
||||
<dd>
|
||||
<a :href="`mailto:${WICOM.email}`">{{ WICOM.email }}</a><br />
|
||||
<a :href="WICOM.phoneHref">{{ WICOM.phone }}</a>
|
||||
</dd>
|
||||
|
||||
<dt>Autor</dt>
|
||||
<dd>{{ WICOM.author }}</dd>
|
||||
|
||||
<dt>Social Media</dt>
|
||||
<dd>
|
||||
<template v-if="WICOM_SOCIAL.length">
|
||||
<a
|
||||
v-for="s in WICOM_SOCIAL"
|
||||
:key="s.url"
|
||||
class="social"
|
||||
:href="s.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ s.label }}</a>
|
||||
</template>
|
||||
<span v-else class="dim">wicom.li führt keine Social-Media-Profile.</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<template #footer>
|
||||
<a class="btn-link" :href="WICOM.url" target="_blank" rel="noopener noreferrer">wicom.li ↗</a>
|
||||
<button class="primary" @click="aboutOpen = false">Schließen</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 9px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.spacer {
|
||||
display: none;
|
||||
}
|
||||
.toolbar {
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-right: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
.brand-mark {
|
||||
height: 24px;
|
||||
flex: none;
|
||||
}
|
||||
.brand-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.wordmark {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
.by {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.by span {
|
||||
font-weight: 600;
|
||||
}
|
||||
.by:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- Über-Dialog --- */
|
||||
.about-lead {
|
||||
margin: 0 0 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.about {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 6px 14px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.about dt {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.about dd {
|
||||
margin: 0;
|
||||
}
|
||||
.about a {
|
||||
color: var(--accent);
|
||||
}
|
||||
.about .dim {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.about .social {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.btn-link {
|
||||
margin-right: auto;
|
||||
align-self: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ro-badge,
|
||||
.status-note {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ro-badge {
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
cursor: help;
|
||||
}
|
||||
.status-note {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.account {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.avatar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.menu-note {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.doc-title {
|
||||
min-width: 0;
|
||||
max-width: 240px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
padding: 4px 7px;
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-title:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
.doc-title:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.gear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 9px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.gear:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.gear.on {
|
||||
background: var(--accent-weak);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- grouped icon-button clusters (segmented-control look) --- */
|
||||
.tgroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ibtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.tgroup.accent .ibtn {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ibtn:hover {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.tgroup.accent .ibtn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ibtn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ibtn.fx em {
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
.menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 50;
|
||||
min-width: 300px;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.menu li.sep {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.menu button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.menu button:hover {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.busy {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.row select,
|
||||
.row input[type='text'] {
|
||||
flex: 1;
|
||||
max-width: 220px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
|
||||
const props = defineProps<{ open: boolean; title: string; wide?: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close');
|
||||
}
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) document.addEventListener('keydown', onKey);
|
||||
else document.removeEventListener('keydown', onKey);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal" :class="{ wide }" role="dialog" aria-modal="true">
|
||||
<header>
|
||||
<h2>{{ title }}</h2>
|
||||
<button class="x" @click="emit('close')">×</button>
|
||||
</header>
|
||||
<div class="body"><slot /></div>
|
||||
<footer><slot name="footer" /></footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 900;
|
||||
}
|
||||
.modal {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
/* never taller than the viewport: header/footer stay put, the body scrolls */
|
||||
max-height: calc(100dvh - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal.wide {
|
||||
width: min(920px, calc(100vw - 32px));
|
||||
}
|
||||
header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.body {
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* wide dialogs lay out one full-height child (e.g. an editor + side panel)
|
||||
that shrinks with the viewport and scrolls its own parts */
|
||||
.modal.wide .body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
footer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
:slotted(button) {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 6px 14px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
:slotted(button.primary) {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A clickable canvas button object -- one of the two ways to trigger a
|
||||
* macro (the other is the "▶ Ausführen" row in MacrosModal.vue). Caption
|
||||
* (`label`) and colours are edited in the inspector (SettingsPanel.vue);
|
||||
* which macro it runs is assignable both there and from this component's
|
||||
* own right-click menu, for a quicker "build a button, wire it up, done"
|
||||
* loop without leaving the canvas.
|
||||
*/
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import ContextMenu from './ContextMenu.vue';
|
||||
|
||||
const props = defineProps<{ objId: string }>();
|
||||
const store = useDocument();
|
||||
const toasts = useToasts();
|
||||
|
||||
const obj = computed(() => {
|
||||
const o = store.objectById(props.objId);
|
||||
return o && o.kind === 'button' ? o : undefined;
|
||||
});
|
||||
|
||||
// A macro can now involve a real network round-trip (GET/POST) and even a
|
||||
// blocking confirm() prompt -- guard against a second click starting a
|
||||
// second overlapping run while one is still in flight.
|
||||
const running = ref(false);
|
||||
|
||||
async function run() {
|
||||
const o = obj.value;
|
||||
if (!o || running.value) return;
|
||||
if (!o.macroId || !store.doc.macros.some((m) => m.id === o.macroId)) {
|
||||
toasts.info('Diesem Button ist noch kein Makro zugewiesen — per Rechtsklick oder im Inspektor.');
|
||||
return;
|
||||
}
|
||||
running.value = true;
|
||||
try {
|
||||
const result = await store.runMacro(o.macroId);
|
||||
if (result.ok) toasts.success(result.message);
|
||||
else toasts.error(result.message);
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const boxStyle = computed(() => {
|
||||
const o = obj.value;
|
||||
if (!o) return {};
|
||||
return {
|
||||
color: o.fg || 'var(--accent)',
|
||||
background: o.bg || 'var(--accent-weak)',
|
||||
} as Record<string, string>;
|
||||
});
|
||||
|
||||
// ---- right-click: run / (re)assign a macro without opening the inspector ----
|
||||
const menu = reactive({ open: false, x: 0, y: 0 });
|
||||
function onContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
menu.x = e.clientX;
|
||||
menu.y = e.clientY;
|
||||
menu.open = true;
|
||||
}
|
||||
const menuItems = computed(() => {
|
||||
const o = obj.value;
|
||||
if (!o) return [];
|
||||
const items: { label: string; action: () => void }[] = [];
|
||||
if (o.macroId) {
|
||||
items.push({ label: '▶ Makro ausführen', action: run });
|
||||
}
|
||||
for (const m of store.doc.macros) {
|
||||
items.push({
|
||||
label: `${m.id === o.macroId ? '✓ ' : ''}Makro: ${m.name}`,
|
||||
action: () => store.setButtonMacroId(o.id, m.id),
|
||||
});
|
||||
}
|
||||
if (store.doc.macros.length === 0) {
|
||||
items.push({ label: 'Kein Makro vorhanden — erst in „▶ Makros" anlegen', action: () => {} });
|
||||
}
|
||||
if (o.macroId) {
|
||||
items.push({ label: 'Zuweisung entfernen', action: () => store.setButtonMacroId(o.id, null) });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="obj"
|
||||
class="btnbox"
|
||||
:class="{ running }"
|
||||
:style="boxStyle"
|
||||
@click="run"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<span class="label">{{ running ? '…' : obj.label || 'Button' }}</span>
|
||||
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btnbox {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 14px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.btnbox.running {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btnbox:hover {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
.btnbox:active {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,401 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Draggable / resizable wrapper for a table, chart, text or button object on
|
||||
* the sheet canvas.
|
||||
*
|
||||
* - the header strip and the frame border move the object;
|
||||
* - the SE handle resizes a chart/text freely; for a table, what it does
|
||||
* depends on the view mode (see `resizeKind` below):
|
||||
* - "Automatisch" (`mode: 'auto'`, size always == content): dragging
|
||||
* drives the row/col *count* directly -- growing adds empty rows/
|
||||
* cols, shrinking removes them, never past real content;
|
||||
* - "Feste Größe" (`mode: 'fixed'`, an explicit scrollable viewport):
|
||||
* dragging only resizes that pixel viewport, independent of row/col
|
||||
* count -- the original behaviour, since a fixed-size table's whole
|
||||
* point is a box smaller than its (possibly much larger) content,
|
||||
* scrolling internally.
|
||||
* - name is editable via double-click, delete via the × button.
|
||||
*
|
||||
* All pointer deltas are divided by `zoom` so dragging tracks the cursor at any
|
||||
* zoom level. Moving and free/viewport resizing both snap to the object's own
|
||||
* sheet's grid spacing (`Sheet.gridSize`, "Raster" in the Settings panel) --
|
||||
* an "Automatisch" table's resize instead already snaps to row/col
|
||||
* boundaries by construction, so it isn't additionally pixel-snapped.
|
||||
*/
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import type { SheetObject, TableObject } from '../store/types';
|
||||
import { DEFAULT_ROW_H, DEFAULT_GRID_SIZE } from '../store/types';
|
||||
import { useDocument } from '../store/document';
|
||||
import TableGrid from './TableGrid.vue';
|
||||
import ChartView from './ChartView.vue';
|
||||
import TextBox from './TextBox.vue';
|
||||
import ButtonView from './ButtonView.vue';
|
||||
|
||||
const props = defineProps<{ object: SheetObject; selected: boolean; zoom: number }>();
|
||||
const emit = defineEmits<{ (e: 'select'): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
const renaming = ref(false);
|
||||
const nameDraft = ref('');
|
||||
const nameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
function startRename() {
|
||||
renaming.value = true;
|
||||
nameDraft.value = props.object.name;
|
||||
nextTick(() => nameInput.value?.select());
|
||||
}
|
||||
function commitRename() {
|
||||
store.renameObject(props.object.id, nameDraft.value);
|
||||
renaming.value = false;
|
||||
}
|
||||
|
||||
// ---- move/resize snap-to-grid --------------------------------------
|
||||
/** This object's own sheet's grid spacing -- also the snap increment while
|
||||
* dragging to move or resize it (SettingsPanel's "Raster" field). */
|
||||
const gridSize = computed(() => {
|
||||
const sheetId = store.sheetIdOfObject(props.object.id);
|
||||
const sheet = sheetId ? store.sheetById(sheetId) : undefined;
|
||||
return sheet?.gridSize ?? DEFAULT_GRID_SIZE;
|
||||
});
|
||||
const snap = (v: number) => Math.round(v / gridSize.value) * gridSize.value;
|
||||
|
||||
// ---- drag to move -------------------------------------------------
|
||||
let drag: { px: number; py: number; ox: number; oy: number } | null = null;
|
||||
function onMoveDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
emit('select');
|
||||
drag = { px: e.clientX, py: e.clientY, ox: props.object.x, oy: props.object.y };
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('mousemove', onMoveMove);
|
||||
window.addEventListener('mouseup', onMoveUp);
|
||||
}
|
||||
function onMoveMove(e: MouseEvent) {
|
||||
if (!drag) return;
|
||||
const dx = (e.clientX - drag.px) / props.zoom;
|
||||
const dy = (e.clientY - drag.py) / props.zoom;
|
||||
store.moveObject(props.object.id, snap(drag.ox + dx), snap(drag.oy + dy));
|
||||
}
|
||||
function onMoveUp() {
|
||||
drag = null;
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onMoveMove);
|
||||
window.removeEventListener('mouseup', onMoveUp);
|
||||
}
|
||||
|
||||
// ---- SE resize --------------------------------------------------
|
||||
let rs: { px: number; py: number; w: number; h: number } | null = null;
|
||||
/** Ghost outline shown *only* while dragging an "Automatisch" table's
|
||||
* handle (see onResizeMove) -- the real row/col-count change is expensive
|
||||
* (a full syncWorkbook() per call) so it commits once, on release; this is
|
||||
* purely a lightweight visual preview in the meantime, touching neither
|
||||
* the store nor the table's actual DOM. */
|
||||
const previewSize = ref<{ w: number; h: number } | null>(null);
|
||||
|
||||
// An "Automatisch" table's resize handle drags the row/col *count* directly
|
||||
// -- these mirror TableGrid.vue's own pixel model (HEAD_W/DEFAULT_W are
|
||||
// duplicated there too).
|
||||
const HEAD_W = 44;
|
||||
const DEFAULT_W = 104;
|
||||
|
||||
/** How many columns fit in `targetPx`, at least 1. */
|
||||
function colsForWidth(t: TableObject, targetPx: number): number {
|
||||
let w = HEAD_W;
|
||||
let c = 0;
|
||||
while (true) {
|
||||
const next = w + (t.colWidths[c] ?? DEFAULT_W);
|
||||
if (next > targetPx && c > 0) break;
|
||||
w = next;
|
||||
c++;
|
||||
}
|
||||
return Math.max(1, c);
|
||||
}
|
||||
/** How many rows fit in `targetPx`, at least 1. */
|
||||
function rowsForHeight(t: TableObject, targetPx: number): number {
|
||||
let h = t.rowHeight ?? DEFAULT_ROW_H;
|
||||
let r = 0;
|
||||
while (true) {
|
||||
const next = h + store.rowHeightOf(t.id, r);
|
||||
if (next > targetPx && r > 0) break;
|
||||
h = next;
|
||||
r++;
|
||||
}
|
||||
return Math.max(1, r);
|
||||
}
|
||||
/** Pixel width/height that exactly fits `t`'s *current* rows/cols -- an
|
||||
* "Automatisch" table has no stored viewport size of its own (it always
|
||||
* renders at its natural content size), so this is the reference point a
|
||||
* resize drag starts from; no explicit "resync" is needed afterwards
|
||||
* either, since the DOM already re-renders at the new natural size the
|
||||
* moment `setTableRows`/`setTableCols` changes `rows`/`cols`. */
|
||||
function fittingSize(t: TableObject): { width: number; height: number } {
|
||||
let width = HEAD_W;
|
||||
for (let c = 0; c < t.cols; c++) width += t.colWidths[c] ?? DEFAULT_W;
|
||||
let height = t.rowHeight ?? DEFAULT_ROW_H;
|
||||
for (let r = 0; r < t.rows; r++) height += store.rowHeightOf(t.id, r);
|
||||
return { width, height };
|
||||
}
|
||||
/** The last row/col index holding a real (non-empty) cell, or -1 -- a
|
||||
* row/col-count shrink must never go below this, or it would silently
|
||||
* delete data. `t.cells` never stores an empty-string entry (an emptied
|
||||
* cell removes its key, see setCellInput), so every present key is real. */
|
||||
function lastNonEmptyRow(t: TableObject): number {
|
||||
let max = -1;
|
||||
for (const k of Object.keys(t.cells)) max = Math.max(max, Number(k.split(',')[1]));
|
||||
return max;
|
||||
}
|
||||
function lastNonEmptyCol(t: TableObject): number {
|
||||
let max = -1;
|
||||
for (const k of Object.keys(t.cells)) max = Math.max(max, Number(k.split(',')[0]));
|
||||
return max;
|
||||
}
|
||||
|
||||
/** How the SE handle behaves for this object -- see the module doc for the
|
||||
* "Automatisch" vs. "Feste Größe" split. */
|
||||
const resizeKind = computed<'free' | 'viewport' | 'grid-size'>(() => {
|
||||
const o = props.object;
|
||||
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') return 'free';
|
||||
return o.view.mode === 'fixed' ? 'viewport' : 'grid-size';
|
||||
});
|
||||
|
||||
function onResizeDown(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emit('select');
|
||||
const o = props.object;
|
||||
let w: number;
|
||||
let h: number;
|
||||
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
|
||||
w = o.width;
|
||||
h = o.height;
|
||||
} else if (o.view.mode === 'fixed') {
|
||||
w = o.view.width;
|
||||
h = o.view.height;
|
||||
} else {
|
||||
const size = fittingSize(o);
|
||||
w = size.width;
|
||||
h = size.height;
|
||||
}
|
||||
rs = { px: e.clientX, py: e.clientY, w, h };
|
||||
document.body.style.userSelect = 'none';
|
||||
window.addEventListener('mousemove', onResizeMove);
|
||||
window.addEventListener('mouseup', onResizeUp);
|
||||
}
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
if (!rs) return;
|
||||
const dx = (e.clientX - rs.px) / props.zoom;
|
||||
const dy = (e.clientY - rs.py) / props.zoom;
|
||||
const o = props.object;
|
||||
if (resizeKind.value === 'free') {
|
||||
if (o.kind === 'chart') store.resizeChart(o.id, snap(rs.w + dx), snap(rs.h + dy));
|
||||
else if (o.kind === 'text') store.resizeText(o.id, snap(rs.w + dx), snap(rs.h + dy));
|
||||
else if (o.kind === 'button') store.resizeButton(o.id, snap(rs.w + dx), snap(rs.h + dy));
|
||||
} else if (resizeKind.value === 'viewport') {
|
||||
// "Feste Größe": pure pixel-viewport resize, independent of row/col
|
||||
// count -- cheap (setTableView never rebuilds the workbook), so a live
|
||||
// update on every mousemove is fine, same as it always was.
|
||||
store.setTableView(o.id, { width: snap(rs.w + dx), height: snap(rs.h + dy) });
|
||||
} else {
|
||||
// "Automatisch": dragging drives row/col *count* -- but that's a full
|
||||
// syncWorkbook() per call, too expensive for every mousemove, so only a
|
||||
// lightweight ghost-box preview updates live here (see the template);
|
||||
// the real change commits once, on release, in onResizeUp.
|
||||
previewSize.value = { w: Math.max(80, rs.w + dx), h: Math.max(52, rs.h + dy) };
|
||||
}
|
||||
}
|
||||
function onResizeUp() {
|
||||
const o = props.object;
|
||||
if (rs && resizeKind.value === 'grid-size' && o.kind === 'table' && previewSize.value) {
|
||||
// Never past non-empty content, so a hard shrink can't silently delete
|
||||
// data -- it just settles at the smallest size that still shows
|
||||
// everything, however far past that the drag actually went.
|
||||
const targetCols = colsForWidth(o, previewSize.value.w);
|
||||
const targetRows = rowsForHeight(o, previewSize.value.h);
|
||||
store.setTableCols(o.id, Math.max(targetCols, lastNonEmptyCol(o) + 1));
|
||||
store.setTableRows(o.id, Math.max(targetRows, lastNonEmptyRow(o) + 1));
|
||||
}
|
||||
previewSize.value = null;
|
||||
rs = null;
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onResizeMove);
|
||||
window.removeEventListener('mouseup', onResizeUp);
|
||||
}
|
||||
|
||||
/** A table with `hideChromeUnfocused` set shows its title bar (and, via the
|
||||
* `selected` prop passed to TableGrid below, its row/column headers) only
|
||||
* while it's the selected object -- a "clean look" presentation option
|
||||
* ("Ohne Rahmen/Kopfzeilen, wenn nicht ausgewählt" in Settings). Every
|
||||
* other object kind is unaffected -- always shown, today's behaviour. */
|
||||
const showChrome = computed(() => {
|
||||
const o = props.object;
|
||||
return props.selected || !(o.kind === 'table' && o.hideChromeUnfocused);
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
switch (props.object.kind) {
|
||||
case 'table':
|
||||
return '▦';
|
||||
case 'chart':
|
||||
return '▤';
|
||||
case 'button':
|
||||
return '▶';
|
||||
case 'text':
|
||||
default:
|
||||
return '¶';
|
||||
}
|
||||
});
|
||||
|
||||
const style = computed(() => {
|
||||
const o = props.object;
|
||||
const base: Record<string, string> = { left: o.x + 'px', top: o.y + 'px' };
|
||||
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
|
||||
base.width = o.width + 'px';
|
||||
base.height = o.height + 'px';
|
||||
}
|
||||
return base;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="canvas-object"
|
||||
:class="{ selected, flex: object.kind === 'chart' || object.kind === 'text' || object.kind === 'button' }"
|
||||
:style="style"
|
||||
@mousedown="emit('select')"
|
||||
>
|
||||
<div v-if="showChrome" class="chrome" @mousedown.stop="onMoveDown">
|
||||
<input
|
||||
v-if="renaming"
|
||||
ref="nameInput"
|
||||
v-model="nameDraft"
|
||||
class="name-edit"
|
||||
@mousedown.stop
|
||||
@blur="commitRename"
|
||||
@keydown.enter.prevent="commitRename"
|
||||
@keydown.esc.prevent="renaming = false"
|
||||
/>
|
||||
<span v-else class="name" @dblclick.stop="startRename">
|
||||
{{ icon }} {{ object.name }}
|
||||
</span>
|
||||
<button
|
||||
class="del"
|
||||
title="Objekt löschen"
|
||||
@mousedown.stop
|
||||
@click.stop="store.deleteObject(object.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content" :class="{ 'full-radius': !showChrome }">
|
||||
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" :selected="selected" />
|
||||
<ChartView v-else-if="object.kind === 'chart'" :obj-id="object.id" />
|
||||
<TextBox v-else-if="object.kind === 'text'" :obj-id="object.id" />
|
||||
<ButtonView v-else-if="object.kind === 'button'" :obj-id="object.id" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="previewSize"
|
||||
class="resize-ghost"
|
||||
:style="{ width: previewSize.w + 'px', height: previewSize.h + 'px' }"
|
||||
/>
|
||||
<div v-if="selected" class="rs-handle" @mousedown="onResizeDown" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-object {
|
||||
position: absolute;
|
||||
background: var(--surface);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.canvas-object.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-weak), var(--shadow);
|
||||
}
|
||||
.canvas-object.flex {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.chrome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 26px;
|
||||
padding: 0 4px 0 10px;
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 10px 10px 0 0;
|
||||
cursor: grab;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.chrome:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.name-edit {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
.del {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.del:hover {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
.content {
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 10px 10px;
|
||||
}
|
||||
/* the chrome bar is what normally rounds the top corners -- without it
|
||||
(hideChromeUnfocused, currently unfocused), .content fills the whole box */
|
||||
.content.full-radius {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.canvas-object.flex .content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.resize-ghost {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
left: 0;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--accent-weak);
|
||||
pointer-events: none;
|
||||
z-index: 7;
|
||||
}
|
||||
.rs-handle {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--surface);
|
||||
cursor: nwse-resize;
|
||||
z-index: 8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* One chart object. Chart.js (bar / line) over data derived live from its
|
||||
* source table: categories = header row (from column B), one series per region
|
||||
* (column A). Any grid edit that touches those cells re-renders immediately.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import Chart from 'chart.js/auto';
|
||||
import { useDocument } from '../store/document';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
import { downloadDataUrl } from '../io/download';
|
||||
import { isNumeric, formatValue } from '../store/format';
|
||||
|
||||
const props = defineProps<{ objId: string }>();
|
||||
|
||||
const store = useDocument();
|
||||
const { isDarkNow, mode: themeMode } = useTheme();
|
||||
|
||||
const obj = computed(() => {
|
||||
const o = store.objectById(props.objId);
|
||||
return o && o.kind === 'chart' ? o : undefined;
|
||||
});
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
let chart: Chart | null = null;
|
||||
|
||||
function cssVar(n: string) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(n).trim();
|
||||
}
|
||||
const palette = () => [
|
||||
cssVar('--series-1'), cssVar('--series-2'), cssVar('--series-3'),
|
||||
cssVar('--series-4'), cssVar('--series-5'),
|
||||
];
|
||||
|
||||
const model = computed(() => {
|
||||
void store.recalcTick.value;
|
||||
const src = obj.value?.sourceTableId ? store.tableById(obj.value.sourceTableId) : undefined;
|
||||
if (!src) return { quarters: [] as string[], regions: [] as string[], rows: [] as number[][] };
|
||||
const quarters: string[] = [];
|
||||
for (let c = 1; c < src.cols; c++) {
|
||||
const v = store.cellValue(src.id, c, 0);
|
||||
if (v === null || v === '') break;
|
||||
quarters.push(String(v));
|
||||
}
|
||||
const regions: string[] = [];
|
||||
const rows: number[][] = [];
|
||||
for (let r = 1; r < src.rows; r++) {
|
||||
const name = store.cellValue(src.id, 0, r);
|
||||
if (name === null || name === '') break;
|
||||
regions.push(String(name));
|
||||
rows.push(
|
||||
quarters.map((_, i) => {
|
||||
const cv = store.cellValue(src.id, i + 1, r);
|
||||
return isNumeric(cv) ? (cv as number) : 0;
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { quarters, regions, rows };
|
||||
});
|
||||
|
||||
const chartLocale = computed(() => store.effectiveLocale(props.objId));
|
||||
|
||||
function valueLabelPlugin(color: string): import('chart.js').Plugin {
|
||||
return {
|
||||
id: 'valueLabels',
|
||||
afterDatasetsDraw(c) {
|
||||
const { ctx } = c;
|
||||
ctx.save();
|
||||
ctx.font = '10px -apple-system, sans-serif';
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
c.data.datasets.forEach((ds, di) => {
|
||||
const meta = c.getDatasetMeta(di);
|
||||
if (meta.hidden) return;
|
||||
meta.data.forEach((pt, i) => {
|
||||
const raw = ds.data[i];
|
||||
if (typeof raw !== 'number') return;
|
||||
ctx.fillText(formatValue(raw, chartLocale.value), pt.x, pt.y - 6);
|
||||
});
|
||||
});
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!canvas.value || !obj.value) return;
|
||||
chart?.destroy();
|
||||
const { quarters, regions, rows } = model.value;
|
||||
const colors = palette();
|
||||
const tick = obj.value.fg || (isDarkNow() ? '#a1a1a6' : '#6e6e73');
|
||||
const grid = isDarkNow() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||
const view = obj.value.view;
|
||||
|
||||
chart = new Chart(canvas.value, {
|
||||
type: view,
|
||||
data: {
|
||||
labels: quarters,
|
||||
datasets: regions.map((region, i) => ({
|
||||
label: region,
|
||||
data: rows[i],
|
||||
backgroundColor: view === 'bar' ? colors[i % colors.length] : colors[i % colors.length] + '33',
|
||||
borderColor: colors[i % colors.length],
|
||||
borderWidth: 2,
|
||||
pointRadius: view === 'line' ? 3 : 0,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 200 },
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { color: tick, boxWidth: 12 } },
|
||||
tooltip: { enabled: true },
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: tick }, grid: { color: grid } },
|
||||
y: { ticks: { color: tick }, grid: { color: grid }, beginAtZero: true },
|
||||
},
|
||||
},
|
||||
plugins: [valueLabelPlugin(tick)],
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(render);
|
||||
onBeforeUnmount(() => chart?.destroy());
|
||||
watch(
|
||||
[
|
||||
model,
|
||||
() => obj.value?.view,
|
||||
() => obj.value?.width,
|
||||
() => obj.value?.height,
|
||||
() => obj.value?.fg,
|
||||
themeMode,
|
||||
chartLocale,
|
||||
],
|
||||
() => {
|
||||
// resize needs a re-render because maintainAspectRatio is false
|
||||
render();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const panelStyle = computed(() => (obj.value?.bg ? { background: obj.value.bg } : {}));
|
||||
|
||||
function exportPng() {
|
||||
if (chart) downloadDataUrl(`${obj.value?.name ?? 'diagramm'}.png`, chart.toBase64Image());
|
||||
}
|
||||
defineExpose({ exportPng });
|
||||
|
||||
// allow the toolbar's "Export gewähltes Diagramm" to reach the active chart
|
||||
function onExportEvent(e: Event) {
|
||||
if ((e as CustomEvent).detail === props.objId) exportPng();
|
||||
}
|
||||
onMounted(() => window.addEventListener('export-chart-png', onExportEvent));
|
||||
onBeforeUnmount(() => window.removeEventListener('export-chart-png', onExportEvent));
|
||||
|
||||
const tableOptions = computed(() =>
|
||||
store.doc.sheets.flatMap((s) => s.objects)
|
||||
.filter((o) => o.kind === 'table')
|
||||
.map((o) => ({ id: o.id, name: o.name })),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="obj" class="chart-view" :style="panelStyle">
|
||||
<div class="bar">
|
||||
<div class="seg">
|
||||
<button :class="{ on: obj.view === 'bar' }" @click.stop="store.setChartView(objId, 'bar')">Balken</button>
|
||||
<button :class="{ on: obj.view === 'line' }" @click.stop="store.setChartView(objId, 'line')">Linie</button>
|
||||
</div>
|
||||
<select
|
||||
class="src"
|
||||
:value="obj.sourceTableId ?? ''"
|
||||
@change="store.setChartSource(objId, ($event.target as HTMLSelectElement).value)"
|
||||
@mousedown.stop
|
||||
>
|
||||
<option v-for="o in tableOptions" :key="o.id" :value="o.id">{{ o.name }}</option>
|
||||
</select>
|
||||
<span class="sp" />
|
||||
<button class="ghost" @click.stop="exportPng">PNG</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<canvas ref="canvas" />
|
||||
<p v-if="model.regions.length === 0" class="empty">Keine Regions-/Quartalsdaten in der Quelltabelle.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sp {
|
||||
flex: 1;
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.seg button {
|
||||
border: 0;
|
||||
background: var(--surface);
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seg button.on {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.src {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
max-width: 130px;
|
||||
}
|
||||
.ghost {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ghost:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
.empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Searchable command reference next to the macro editor (MacrosModal.vue)
|
||||
* and the custom-function editor (CustomFunctionsModal.vue). Static entries
|
||||
* come from data/codeReference.ts, filtered to `context`; the "Im Dokument"
|
||||
* category is built live from the current document (tables and sheets for
|
||||
* macros, named ranges, custom functions) so their exact, correctly quoted
|
||||
* names can be inserted with one click, and a function's own parameters get
|
||||
* a "Parameter" category. Emits `insert` with the text to put at the
|
||||
* editor's caret.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import {
|
||||
REF_CATEGORIES,
|
||||
matchesQuery,
|
||||
referenceFor,
|
||||
type RefContext,
|
||||
type RefEntry,
|
||||
} from '../data/codeReference';
|
||||
|
||||
/** A listed entry -- static ones are already filtered to `context`. */
|
||||
type Entry = Omit<RefEntry, 'contexts'>;
|
||||
|
||||
const props = defineProps<{ context: RefContext; params?: string[] }>();
|
||||
const emit = defineEmits<{ (e: 'insert', text: string): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
const DOC_CATEGORY = 'Im Dokument';
|
||||
const PARAM_CATEGORY = 'Parameter';
|
||||
type Category = (typeof REF_CATEGORIES)[number] | typeof DOC_CATEGORY | typeof PARAM_CATEGORY;
|
||||
|
||||
const query = ref('');
|
||||
const category = ref<Category | null>(null);
|
||||
const expanded = ref<string | null>(null);
|
||||
|
||||
/** Quote a table/sheet name for a reference qualifier when it isn't a bare word. */
|
||||
const qualifier = (name: string) => (/^[\p{L}_][\p{L}\d_]*$/u.test(name) ? name : `'${name.replace(/'/g, "''")}'`);
|
||||
|
||||
const docEntries = computed<Entry[]>(() => {
|
||||
const d = store.doc;
|
||||
const out: Entry[] = [];
|
||||
const tableCount = new Map<string, number>();
|
||||
for (const s of d.sheets)
|
||||
for (const o of s.objects)
|
||||
if (o.kind === 'table') tableCount.set(o.name.toLowerCase(), (tableCount.get(o.name.toLowerCase()) ?? 0) + 1);
|
||||
// sheets/tables are only ever named (as text or qualifier) by a macro
|
||||
for (const s of props.context === 'macro' ? d.sheets : []) {
|
||||
out.push({
|
||||
name: s.name,
|
||||
category: 'Blätter',
|
||||
syntax: `"${s.name}"`,
|
||||
description: 'Blatt in diesem Dokument — als Text für ADDTABLE, RENAMESHEET, DELETESHEET.',
|
||||
snippet: `"${s.name}"`,
|
||||
keywords: 'blatt sheet',
|
||||
});
|
||||
for (const o of s.objects) {
|
||||
if (o.kind !== 'table') continue;
|
||||
// a table name used on several sheets must carry its sheet
|
||||
const ambiguous = (tableCount.get(o.name.toLowerCase()) ?? 0) > 1;
|
||||
const textRef = ambiguous ? `${s.name}!${o.name}` : o.name;
|
||||
const cellRef = (ambiguous ? `${qualifier(s.name)}!` : '') + `${qualifier(o.name)}::A1`;
|
||||
out.push({
|
||||
name: o.name,
|
||||
category: 'Tabellen',
|
||||
syntax: `"${textRef}" ${cellRef}`,
|
||||
description: `Tabelle auf Blatt „${s.name}" (${o.rows} × ${o.cols}). Als Text für Zeilen-/Spalten-/Tabellen-Aktionen, mit ::A1 als Zellbezug.`,
|
||||
snippet: `"${textRef}"`,
|
||||
example: `INSERTROW("${textRef}"; 0)\nSETVALUE(${cellRef}; "Neu")`,
|
||||
keywords: 'tabelle table',
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const nr of d.namedRanges) {
|
||||
out.push({
|
||||
name: nr.name,
|
||||
category: 'Zellen',
|
||||
syntax: nr.name,
|
||||
description: 'Benannter Bereich — überall als Bezug nutzbar, auch als SETVALUE-Ziel.',
|
||||
snippet: nr.name,
|
||||
keywords: 'name bereich benannt',
|
||||
});
|
||||
}
|
||||
for (const fn of d.customFunctions) {
|
||||
out.push({
|
||||
name: fn.name,
|
||||
category: 'Formelfunktionen',
|
||||
syntax: `${fn.name}(${fn.params.join('; ')})`,
|
||||
description: 'Eigene Funktion dieses Dokuments — berechnet einen Wert, schreibt selbst nie.',
|
||||
snippet: `${fn.name}(${fn.params.join('; ')})`,
|
||||
keywords: 'eigene funktion benutzerdefiniert',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const paramEntries = computed<Entry[]>(() =>
|
||||
(props.params ?? [])
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.map((p) => ({
|
||||
name: p,
|
||||
category: 'Operatoren',
|
||||
syntax: p,
|
||||
description: 'Parameter dieser Funktion — steht für den Wert (oder Bereich), mit dem sie aufgerufen wird.',
|
||||
snippet: p,
|
||||
keywords: 'parameter argument',
|
||||
})),
|
||||
);
|
||||
|
||||
const available = computed<Array<Entry & { group: Category }>>(() => [
|
||||
...paramEntries.value.map((e) => ({ ...e, group: PARAM_CATEGORY as Category })),
|
||||
...referenceFor(props.context).map((e) => ({ ...e, group: e.category as Category })),
|
||||
...docEntries.value.map((e) => ({ ...e, group: DOC_CATEGORY as Category })),
|
||||
]);
|
||||
|
||||
/** Only categories that have something in this context, in display order. */
|
||||
const categories = computed<Category[]>(() =>
|
||||
([PARAM_CATEGORY, ...REF_CATEGORIES, DOC_CATEGORY] as Category[]).filter((c) =>
|
||||
available.value.some((e) => e.group === c),
|
||||
),
|
||||
);
|
||||
|
||||
// switching "Mit Schleife" -> "Einfache Formel" can remove the chosen category
|
||||
watch(categories, (cs) => {
|
||||
if (category.value && !cs.includes(category.value)) category.value = null;
|
||||
});
|
||||
|
||||
const placeholder = computed(() =>
|
||||
props.context === 'macro'
|
||||
? 'Befehl suchen … (z. B. zeile, schleife, json)'
|
||||
: 'Suchen … (z. B. summe, schleife, wenn)',
|
||||
);
|
||||
|
||||
const entries = computed(() =>
|
||||
available.value.filter((e) => (!category.value || e.group === category.value) && matchesQuery(e, query.value)),
|
||||
);
|
||||
|
||||
const grouped = computed(() =>
|
||||
categories.value
|
||||
.map((c) => ({ category: c, items: entries.value.filter((e) => e.group === c) }))
|
||||
.filter((g) => g.items.length),
|
||||
);
|
||||
|
||||
const keyOf = (e: Entry & { group: Category }) => `${e.group}/${e.category}/${e.name}`;
|
||||
|
||||
function toggle(key: string) {
|
||||
expanded.value = expanded.value === key ? null : key;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="ref" aria-label="Befehlsreferenz">
|
||||
<input
|
||||
v-model="query"
|
||||
class="search"
|
||||
type="search"
|
||||
:placeholder="placeholder"
|
||||
aria-label="Befehle durchsuchen"
|
||||
/>
|
||||
<div class="chips">
|
||||
<button class="chip" :class="{ on: !category }" @click="category = null">Alle</button>
|
||||
<button
|
||||
v-for="c in categories"
|
||||
:key="c"
|
||||
class="chip"
|
||||
:class="{ on: category === c }"
|
||||
@click="category = category === c ? null : c"
|
||||
>
|
||||
{{ c }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
<p v-if="!grouped.length" class="empty">Keine Treffer für „{{ query }}".</p>
|
||||
<section v-for="g in grouped" :key="g.category">
|
||||
<h4>{{ g.category }}</h4>
|
||||
<div v-for="e in g.items" :key="keyOf(e)" class="entry" :class="{ open: expanded === keyOf(e) }">
|
||||
<div class="head">
|
||||
<button class="title" :aria-expanded="expanded === keyOf(e)" @click="toggle(keyOf(e))">
|
||||
<code>{{ e.syntax }}</code>
|
||||
</button>
|
||||
<button class="ins" title="An der Cursorposition einfügen" @click="emit('insert', e.snippet)">
|
||||
Einfügen
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="expanded === keyOf(e)" class="detail">
|
||||
<p>{{ e.description }}</p>
|
||||
<template v-if="e.example">
|
||||
<pre>{{ e.example }}</pre>
|
||||
<button class="ins" @click="emit('insert', e.example)">Beispiel einfügen</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ref {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
.search {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.search:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.chip {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-dim);
|
||||
border-radius: 999px;
|
||||
padding: 2px 9px;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.chip.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.entry {
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px 4px 8px;
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 2px 0;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.title code {
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.title:hover code {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ins {
|
||||
flex: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ins:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.detail {
|
||||
padding: 0 8px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.detail p {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.detail pre {
|
||||
margin: 0 0 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 11.5px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
|
||||
interface Item {
|
||||
label: string;
|
||||
action: () => void;
|
||||
}
|
||||
const props = defineProps<{ open: boolean; x: number; y: number; items: Item[] }>();
|
||||
const emit = defineEmits<{ (e: 'update:open', v: boolean): void }>();
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
function onDocClick() {
|
||||
close();
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
// defer so the opening contextmenu event doesn't immediately close it
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', onDocClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
});
|
||||
} else {
|
||||
document.removeEventListener('click', onDocClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
function run(item: Item) {
|
||||
item.action();
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<ul
|
||||
v-if="open"
|
||||
class="ctx"
|
||||
:style="{ left: x + 'px', top: y + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<li v-for="item in items" :key="item.label">
|
||||
<button type="button" @click="run(item)">{{ item.label }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ctx {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.ctx button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.ctx button:hover {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "𝑓 Funktionen" -- the exclusive UI for creating, viewing, editing and
|
||||
* deleting the document's user-defined functions (src/engine/userFunctions.ts).
|
||||
* Two views in one BaseModal, toggled by `mode`: a list of existing functions,
|
||||
* and a form shared by both "new" and "edit". See the plan's "Bedienung"
|
||||
* section for the exact mockups this follows.
|
||||
*/
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import CodeReference from './CodeReference.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
type Mode = 'list' | 'edit';
|
||||
type Kind = 'expression' | 'block';
|
||||
const mode = ref<Mode>('list');
|
||||
const editingId = ref<string | null>(null);
|
||||
const form = reactive({ name: '', params: [''] as string[], body: '', kind: 'expression' as Kind });
|
||||
const formError = ref<string | null>(null);
|
||||
|
||||
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
|
||||
// -progress never lingers into the next open.
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) {
|
||||
mode.value = 'list';
|
||||
formError.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
form.name = '';
|
||||
form.params = [''];
|
||||
form.body = '';
|
||||
form.kind = 'expression';
|
||||
formError.value = null;
|
||||
mode.value = 'edit';
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
const f = store.doc.customFunctions.find((x) => x.id === id);
|
||||
if (!f) return;
|
||||
editingId.value = id;
|
||||
form.name = f.name;
|
||||
form.params = f.params.length ? [...f.params] : [''];
|
||||
form.body = f.body;
|
||||
form.kind = f.kind;
|
||||
formError.value = null;
|
||||
mode.value = 'edit';
|
||||
}
|
||||
|
||||
function addParamField() {
|
||||
form.params.push('');
|
||||
}
|
||||
function removeParamField(i: number) {
|
||||
form.params.splice(i, 1);
|
||||
}
|
||||
|
||||
function save() {
|
||||
const params = form.params.map((p) => p.trim()).filter((p) => p !== '');
|
||||
const result = editingId.value
|
||||
? store.updateCustomFunction(editingId.value, form.name, params, form.body, form.kind)
|
||||
: store.addCustomFunction(form.name, params, form.body, form.kind);
|
||||
if (result && typeof result === 'object' && 'error' in result) {
|
||||
formError.value = result.error;
|
||||
return;
|
||||
}
|
||||
formError.value = null;
|
||||
mode.value = 'list';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
formError.value = null;
|
||||
mode.value = 'list';
|
||||
}
|
||||
|
||||
function removeFunction(id: string, name: string) {
|
||||
if (confirm(`Funktion "${name}" löschen? Formeln, die sie aufrufen, zeigen danach #NAME?.`)) {
|
||||
store.deleteCustomFunction(id);
|
||||
}
|
||||
}
|
||||
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
/** Put a reference snippet at the caret (replacing any selection). */
|
||||
async function insertSnippet(text: string) {
|
||||
const el = bodyInput.value;
|
||||
const start = el?.selectionStart ?? form.body.length;
|
||||
const end = el?.selectionEnd ?? form.body.length;
|
||||
form.body = form.body.slice(0, start) + text + form.body.slice(end);
|
||||
await nextTick();
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(start + text.length, start + text.length);
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
:wide="mode === 'edit'"
|
||||
:title="mode === 'list' ? 'Funktionen' : editingId ? 'Funktion bearbeiten' : 'Neue Funktion'"
|
||||
@close="close"
|
||||
>
|
||||
<!-- ---- list view ---- -->
|
||||
<template v-if="mode === 'list'">
|
||||
<p v-if="!store.doc.customFunctions.length" class="hint">
|
||||
Noch keine Funktionen definiert. Mit „+ Neue Funktion" eine anlegen und danach wie
|
||||
<code>SUM</code> oder <code>AVG</code> in einer Zelle aufrufen.
|
||||
</p>
|
||||
<ul v-else class="fn-list">
|
||||
<li v-for="f in store.doc.customFunctions" :key="f.id" class="fn-row">
|
||||
<code class="fn-sig">
|
||||
<span v-if="f.kind === 'block'" class="fn-badge" title="Mit Schleife (Code)">↻</span>
|
||||
{{ f.name }}({{ f.params.join('; ') }})
|
||||
</code>
|
||||
<span class="fn-actions">
|
||||
<button class="mini" @click="openEdit(f.id)">Bearb.</button>
|
||||
<button class="mini danger" title="Löschen" @click="removeFunction(f.id, f.name)">🗑</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- ---- edit / create form ---- -->
|
||||
<div v-else class="edit-layout">
|
||||
<div class="editor">
|
||||
<label class="row">
|
||||
Name
|
||||
<input v-model="form.name" type="text" placeholder="z. B. MARGE" />
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<span class="params-label">Art</span>
|
||||
<div class="kind-toggle">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: form.kind === 'expression' }"
|
||||
@click="form.kind = 'expression'"
|
||||
>
|
||||
Einfache Formel
|
||||
</button>
|
||||
<button type="button" :class="{ on: form.kind === 'block' }" @click="form.kind = 'block'">
|
||||
Mit Schleife
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row params-row">
|
||||
<span class="params-label">Parameter</span>
|
||||
<div class="params-list">
|
||||
<div v-for="(_, i) in form.params" :key="i" class="param-field">
|
||||
<input v-model="form.params[i]" type="text" :placeholder="`param${i + 1}`" />
|
||||
<button
|
||||
class="mini remove"
|
||||
title="Parameter entfernen"
|
||||
:disabled="form.params.length <= 1"
|
||||
@click="removeParamField(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button class="mini" @click="addParamField">+ Parameter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="row body-row">
|
||||
{{ form.kind === 'block' ? 'Code' : 'Formel' }}
|
||||
<textarea
|
||||
ref="bodyInput"
|
||||
v-model="form.body"
|
||||
class="formula-input"
|
||||
spellcheck="false"
|
||||
:rows="form.kind === 'block' ? 12 : 5"
|
||||
:placeholder="
|
||||
form.kind === 'block'
|
||||
? 'ergebnis = 1\nFOR x IN bereich DO\n ergebnis = ergebnis * x\nEND\nRETURN ergebnis'
|
||||
: 'z. B. (revenue - cost) / revenue'
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Befehle, Funktionen und die eigenen Parameter rechts suchen, aufklappen für
|
||||
Beschreibung und Beispiel, mit <strong>Einfügen</strong> an der Cursorposition
|
||||
übernehmen.
|
||||
</p>
|
||||
|
||||
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
||||
</div>
|
||||
<CodeReference
|
||||
class="reference"
|
||||
:context="form.kind"
|
||||
:params="form.params"
|
||||
@insert="insertSnippet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="mode === 'list'">
|
||||
<button class="primary" @click="openNew">+ Neue Funktion</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button @click="cancelEdit">Abbrechen</button>
|
||||
<button class="primary" @click="save">Speichern</button>
|
||||
</template>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.hint code {
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.fn-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.fn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.fn-sig {
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fn-badge {
|
||||
display: inline-block;
|
||||
margin-right: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.kind-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.kind-toggle button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.kind-toggle button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.kind-toggle button.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.fn-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.mini.danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.row input[type='text'] {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.params-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.param-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.param-field input {
|
||||
width: 92px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 7px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.remove {
|
||||
padding: 2px 7px;
|
||||
}
|
||||
.body-row .formula-input {
|
||||
min-height: 30px;
|
||||
max-height: 40vh;
|
||||
}
|
||||
.formula-input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
resize: vertical;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.formula-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
.edit-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
/* side by side: fill the (viewport-capped) modal body and let the
|
||||
reference list scroll inside it instead of the whole body */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
/* stacked: let the modal body scroll as a whole */
|
||||
.edit-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.editor .body-row {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor .body-row .formula-input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 70px;
|
||||
max-height: none;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.form-error {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #dc2626;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Interactive dependency-graph explorer, docked to the right (like
|
||||
* SettingsPanel.vue docks left) -- NOT a full-screen modal: the canvas and
|
||||
* sheet tabs stay visible and interactive the whole time, so navigating the
|
||||
* graph never loses track of which sheet/table you're actually looking at.
|
||||
*
|
||||
* A single document-wide instance (controlled via `store.ui.depGraph`, the
|
||||
* same pattern as `settingsOpen`), not owned by any one `TableGrid` -- opened
|
||||
* from a cell's context menu ("🕸 Abhängigkeiten anzeigen"). Clicking any
|
||||
* precedent/dependent node *really* navigates there (store.openDependencyGraph
|
||||
* also calls selectCell, which switches the active sheet/object as a side
|
||||
* effect) -- the drawer re-centers on it exactly like a fresh open would.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import type { CellGraphNode } from '../store/types';
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
interface Loc {
|
||||
tableId: string;
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
const loc = computed(() => store.ui.depGraph);
|
||||
const open = computed(() => loc.value !== null);
|
||||
|
||||
const history = ref<Loc[]>([]);
|
||||
// a closed -> open transition always starts a fresh navigation history
|
||||
watch(open, (isOpen, wasOpen) => {
|
||||
if (isOpen && !wasOpen) history.value = [];
|
||||
});
|
||||
|
||||
function goTo(node: CellGraphNode) {
|
||||
if (loc.value) history.value.push(loc.value);
|
||||
store.openDependencyGraph(node.tableId, node.col, node.row);
|
||||
}
|
||||
function goBack() {
|
||||
const prev = history.value.pop();
|
||||
if (prev) store.openDependencyGraph(prev.tableId, prev.col, prev.row);
|
||||
}
|
||||
function close() {
|
||||
store.closeDependencyGraph();
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) document.addEventListener('keydown', onKey);
|
||||
else document.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
const self = computed(() => (loc.value ? store.cellGraphSelf(loc.value.tableId, loc.value.col, loc.value.row) : null));
|
||||
const neighbors = computed(() =>
|
||||
loc.value
|
||||
? store.cellGraphNeighbors(loc.value.tableId, loc.value.col, loc.value.row)
|
||||
: { precedents: [], dependents: [] },
|
||||
);
|
||||
|
||||
/** Always "Blatt · Tabelle!Zelle" -- the sheet name is never omitted here
|
||||
* (unlike an in-formula reference, which only qualifies when needed): the
|
||||
* whole point of this panel is never losing track of *where* something is. */
|
||||
function label(node: CellGraphNode): string {
|
||||
return `${node.sheetName ? node.sheetName + ' · ' : ''}${node.tableName}!${node.label}`;
|
||||
}
|
||||
function display(node: CellGraphNode): string {
|
||||
return store.cellDisplay(node.tableId, node.col, node.row);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="dg-drawer" :class="{ open }" aria-label="Abhängigkeiten">
|
||||
<header>
|
||||
<button class="back" :disabled="!history.length" title="Zurück" @click="goBack">←</button>
|
||||
<h2>🕸 Abhängigkeiten</h2>
|
||||
<button class="x" title="Schließen" @click="close">×</button>
|
||||
</header>
|
||||
|
||||
<div v-if="self" class="body">
|
||||
<section class="self-card" :class="{ circular: self.isCircular }">
|
||||
<p v-if="self.isCircular" class="circ-warn">⚠ Teil eines Zirkelbezugs</p>
|
||||
<p class="cell-label">{{ label(self) }}</p>
|
||||
<p class="cell-value">{{ display(self) }}</p>
|
||||
<p v-if="self.raw" class="cell-raw">{{ self.raw }}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Vorgänger — liest von ({{ neighbors.precedents.length }})</h3>
|
||||
<p v-if="!neighbors.precedents.length" class="empty">keine</p>
|
||||
<ul v-else class="node-list">
|
||||
<li v-for="(n, i) in neighbors.precedents" :key="'p' + i">
|
||||
<button
|
||||
type="button"
|
||||
class="node-btn"
|
||||
:class="{ circular: n.isCircular }"
|
||||
:title="n.raw || display(n)"
|
||||
@click="goTo(n)"
|
||||
>
|
||||
<span v-if="n.isCircular" class="warn">⚠</span>
|
||||
<span class="node-label">{{ label(n) }}</span>
|
||||
<span class="node-value">{{ display(n) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Nachfolger — liest davon ({{ neighbors.dependents.length }})</h3>
|
||||
<p v-if="!neighbors.dependents.length" class="empty">keine</p>
|
||||
<ul v-else class="node-list">
|
||||
<li v-for="(n, i) in neighbors.dependents" :key="'d' + i">
|
||||
<button
|
||||
type="button"
|
||||
class="node-btn"
|
||||
:class="{ circular: n.isCircular }"
|
||||
:title="n.raw || display(n)"
|
||||
@click="goTo(n)"
|
||||
>
|
||||
<span v-if="n.isCircular" class="warn">⚠</span>
|
||||
<span class="node-label">{{ label(n) }}</span>
|
||||
<span class="node-value">{{ display(n) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<p v-else class="body-empty">Zelle nicht gefunden.</p>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dg-drawer {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 380px;
|
||||
max-width: 88vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateX(102%);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.dg-drawer.open {
|
||||
transform: none;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex: none;
|
||||
}
|
||||
h2 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.back,
|
||||
.x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
flex: none;
|
||||
}
|
||||
.back {
|
||||
font-size: 15px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
}
|
||||
.back:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.back:not(:disabled):hover,
|
||||
.x:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.x {
|
||||
font-size: 18px;
|
||||
}
|
||||
.body {
|
||||
overflow-y: auto;
|
||||
padding: 4px 14px 20px;
|
||||
}
|
||||
.body-empty {
|
||||
padding: 14px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
section {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.self-card {
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.self-card.circular {
|
||||
background: #dc2626;
|
||||
}
|
||||
.cell-label {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.cell-value {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12.5px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.cell-raw {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, monospace;
|
||||
opacity: 0.8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.circ-warn {
|
||||
margin: 0 0 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.node-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.node-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
padding: 6px 9px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.node-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.node-btn.circular {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
.node-label {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.node-value {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 40%;
|
||||
}
|
||||
.warn {
|
||||
font-size: 10.5px;
|
||||
color: #dc2626;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "Dokumente" -- every document the user can open, in up to three groups:
|
||||
* their account's documents (server), documents shared with them (server:
|
||||
* invitations, links), and documents stored only in this browser
|
||||
* (IndexedDB). Open, rename, duplicate, delete/leave, upload, create.
|
||||
*/
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useLibrary, type DocRef } from '../store/library';
|
||||
import { useSession } from '../store/session';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'login'): void }>();
|
||||
|
||||
const library = useLibrary();
|
||||
const session = useSession();
|
||||
|
||||
interface Row {
|
||||
ref: DocRef;
|
||||
title: string;
|
||||
updatedAt: number;
|
||||
note: string;
|
||||
canRename: boolean;
|
||||
canDuplicate: boolean;
|
||||
removeLabel: string;
|
||||
canUpload: boolean;
|
||||
}
|
||||
|
||||
const ROLE_NOTE = { owner: '', editor: 'bearbeiten', viewer: 'nur lesen' } as const;
|
||||
|
||||
const groups = computed(() => {
|
||||
const own: Row[] = [];
|
||||
const shared: Row[] = [];
|
||||
for (const d of library.state.serverDocs) {
|
||||
const row: Row = {
|
||||
ref: { source: 'server', id: d.id },
|
||||
title: d.title,
|
||||
updatedAt: Date.parse(d.updated_at),
|
||||
note: d.role === 'owner' ? '' : `von ${d.owner_name} · ${ROLE_NOTE[d.role]}`,
|
||||
canRename: d.role !== 'viewer',
|
||||
canDuplicate: true,
|
||||
removeLabel: d.role === 'owner' ? 'Löschen' : 'Entfernen',
|
||||
canUpload: false,
|
||||
};
|
||||
(d.role === 'owner' ? own : shared).push(row);
|
||||
}
|
||||
for (const l of library.state.linkDocs) {
|
||||
shared.push({
|
||||
ref: { source: 'server', id: l.id },
|
||||
title: l.title,
|
||||
updatedAt: 0,
|
||||
note: `per Link · ${ROLE_NOTE[l.role]}`,
|
||||
canRename: false,
|
||||
canDuplicate: false,
|
||||
removeLabel: 'Entfernen',
|
||||
canUpload: false,
|
||||
});
|
||||
}
|
||||
const local: Row[] = library.state.docs.map((d) => ({
|
||||
ref: { source: 'local', id: d.id },
|
||||
title: d.title,
|
||||
updatedAt: d.updatedAt,
|
||||
note: '',
|
||||
canRename: true,
|
||||
canDuplicate: true,
|
||||
removeLabel: 'Löschen',
|
||||
canUpload: session.signedIn.value,
|
||||
}));
|
||||
return [
|
||||
{ key: 'own', title: 'Mein Konto', rows: own, show: session.signedIn.value },
|
||||
{ key: 'shared', title: 'Mit mir geteilt', rows: shared, show: shared.length > 0 },
|
||||
{
|
||||
key: 'local',
|
||||
title: session.signedIn.value ? 'Nur in diesem Browser' : 'In diesem Browser',
|
||||
rows: local,
|
||||
show: library.state.available && (local.length > 0 || !session.signedIn.value),
|
||||
},
|
||||
].filter((g) => g.show);
|
||||
});
|
||||
|
||||
const isCurrent = (r: DocRef) =>
|
||||
library.state.current?.source === r.source && library.state.current.id === r.id;
|
||||
const keyOf = (r: DocRef) => `${r.source}:${r.id}`;
|
||||
|
||||
const renaming = ref<string | null>(null);
|
||||
const renameValue = ref('');
|
||||
const renameInput = ref<HTMLInputElement[] | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) {
|
||||
renaming.value = null;
|
||||
void library.refreshServerList();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('de-CH', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
function when(ms: number): string {
|
||||
if (!ms) return '';
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 60_000) return 'gerade eben';
|
||||
if (diff < 3_600_000) return `vor ${Math.floor(diff / 60_000)} Min.`;
|
||||
return fmt.format(ms);
|
||||
}
|
||||
function subline(r: Row): string {
|
||||
return [isCurrent(r.ref) ? 'geöffnet' : '', r.note, when(r.updatedAt)].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
async function openDoc(r: Row) {
|
||||
if (renaming.value) return;
|
||||
if (await library.open(r.ref)) emit('close');
|
||||
}
|
||||
async function startRename(r: Row) {
|
||||
renaming.value = keyOf(r.ref);
|
||||
renameValue.value = r.title;
|
||||
await nextTick();
|
||||
renameInput.value?.[0]?.select();
|
||||
}
|
||||
async function commitRename(r: Row) {
|
||||
if (renaming.value !== keyOf(r.ref)) return;
|
||||
renaming.value = null;
|
||||
if (renameValue.value.trim() && renameValue.value !== r.title) await library.rename(r.ref, renameValue.value);
|
||||
}
|
||||
async function remove(r: Row) {
|
||||
const q =
|
||||
r.removeLabel === 'Löschen'
|
||||
? `„${r.title}" endgültig löschen?`
|
||||
: `„${r.title}" aus deiner Liste entfernen? Du verlierst den Zugriff, bis es wieder mit dir geteilt wird.`;
|
||||
if (confirm(q)) await library.remove(r.ref);
|
||||
}
|
||||
async function upload(r: Row) {
|
||||
await library.upload([r.ref.id]);
|
||||
}
|
||||
async function createNew() {
|
||||
await library.createBlank();
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :open="open" title="Dokumente" wide @close="emit('close')">
|
||||
<p v-if="!library.state.available && !session.signedIn.value" class="hint">
|
||||
In diesem Browser können keine Dokumente gespeichert werden (IndexedDB nicht verfügbar, z. B. im
|
||||
privaten Modus). Mit einem Konto werden Dokumente auf dem Server gespeichert.
|
||||
</p>
|
||||
|
||||
<section v-for="g in groups" :key="g.key" class="group">
|
||||
<h3>{{ g.title }}</h3>
|
||||
<p v-if="!g.rows.length" class="empty">Noch keine Dokumente.</p>
|
||||
<ul v-else class="doc-list">
|
||||
<li v-for="r in g.rows" :key="keyOf(r.ref)" class="doc-row" :class="{ current: isCurrent(r.ref) }">
|
||||
<input
|
||||
v-if="renaming === keyOf(r.ref)"
|
||||
ref="renameInput"
|
||||
v-model="renameValue"
|
||||
class="rename"
|
||||
type="text"
|
||||
@keydown.enter.prevent="commitRename(r)"
|
||||
@keydown.esc.stop.prevent="renaming = null"
|
||||
@blur="commitRename(r)"
|
||||
/>
|
||||
<button v-else class="doc-open" :title="`${r.title} öffnen`" @click="openDoc(r)">
|
||||
<span class="doc-name">{{ r.title }}</span>
|
||||
<span class="doc-when">{{ subline(r) }}</span>
|
||||
</button>
|
||||
<span class="doc-actions">
|
||||
<button v-if="r.canUpload" class="mini" title="In dein Konto hochladen (danach teilbar)" @click="upload(r)">
|
||||
Hochladen
|
||||
</button>
|
||||
<button v-if="r.canRename" class="mini" @click="startRename(r)">Umbenennen</button>
|
||||
<button v-if="r.canDuplicate" class="mini" @click="library.duplicate(r.ref)">Duplizieren</button>
|
||||
<button class="mini danger" @click="remove(r)">{{ r.removeLabel }}</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!session.signedIn.value" class="hint">
|
||||
Dokumente werden nur in diesem Browser gespeichert.
|
||||
<button class="linkish" @click="emit('login')">Anmelden</button>, um sie auf allen Geräten zu haben und zu
|
||||
teilen.
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<button @click="emit('close')">Schließen</button>
|
||||
<button class="primary" :disabled="!library.state.available && !session.signedIn.value" @click="createNew">
|
||||
+ Neues Dokument
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.group + .group {
|
||||
margin-top: 16px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: min(40vh, 360px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.doc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.doc-row.current {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.doc-open {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 4px 6px;
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-open:hover .doc-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
.doc-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.doc-when {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.rename {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.doc-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.doc-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini.danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,378 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { cycleReferenceLocks } from '../engine';
|
||||
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
|
||||
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import FormulaSuggest from './FormulaSuggest.vue';
|
||||
|
||||
const store = useDocument();
|
||||
const toast = useToasts();
|
||||
const draft = ref('');
|
||||
const focused = ref(false);
|
||||
const fieldEl = ref<HTMLTextAreaElement | null>(null);
|
||||
const autocomplete = useFormulaAutocomplete();
|
||||
const keyboardPoint = useKeyboardPointMode();
|
||||
|
||||
// keep the field in sync with the active cell unless the user is editing it
|
||||
watch(
|
||||
() => store.activeCellInput.value,
|
||||
(v) => {
|
||||
if (!focused.value) draft.value = v;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// ---- Name Box (top-left) ---------------------------------------------
|
||||
// Doubles as (1) a live "where am I" display (e.g. "Umsatz::B2:E6") and (2)
|
||||
// a jump/select field: type a cell/range/table/sheet/named-range name and
|
||||
// press Enter -- see document.ts's `jumpToNameInput` for exactly what it
|
||||
// accepts. Autocompletes against named ranges, table names, and sheet
|
||||
// names (`nameBoxSuggestions`), same dropdown component as the formula
|
||||
// function-name autocomplete.
|
||||
const nameDraft = ref('');
|
||||
const nameFocused = ref(false);
|
||||
const nameActiveIndex = ref(0);
|
||||
const nameEl = ref<HTMLInputElement | null>(null);
|
||||
const nameSuggestions = computed(() =>
|
||||
nameFocused.value ? store.nameBoxSuggestions(nameDraft.value) : [],
|
||||
);
|
||||
|
||||
watch(
|
||||
() => store.selectionRangeName.value,
|
||||
(v) => {
|
||||
if (!nameFocused.value) nameDraft.value = v;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function onNameFocus() {
|
||||
nameFocused.value = true;
|
||||
nameActiveIndex.value = 0;
|
||||
nextTick(() => nameEl.value?.select());
|
||||
}
|
||||
function onNameBlur() {
|
||||
nameFocused.value = false;
|
||||
nameDraft.value = store.selectionRangeName.value; // discard an unconfirmed edit
|
||||
}
|
||||
function onNameInput() {
|
||||
nameActiveIndex.value = 0;
|
||||
}
|
||||
/** Tries to navigate to `text`; on success blurs back to the grid (so the
|
||||
* Name Box promptly shows the new selection), on failure keeps the field
|
||||
* focused/selected (so the user can see and immediately correct it) and
|
||||
* reports the problem via a toast, same as other invalid-input feedback
|
||||
* in this app. */
|
||||
function jumpTo(text: string) {
|
||||
if (store.jumpToNameInput(text)) {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
} else {
|
||||
toast.error(`„${text}" ist kein gültiger Bezug oder Name.`);
|
||||
nextTick(() => nameEl.value?.select());
|
||||
}
|
||||
}
|
||||
function onNamePick(i: number) {
|
||||
const item = nameSuggestions.value[i];
|
||||
if (item) jumpTo(item.name);
|
||||
}
|
||||
function onNameKeydown(e: KeyboardEvent) {
|
||||
const hasDropdown = nameSuggestions.value.length > 0;
|
||||
if (hasDropdown && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
e.preventDefault();
|
||||
const n = nameSuggestions.value.length;
|
||||
nameActiveIndex.value = (nameActiveIndex.value + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
|
||||
return;
|
||||
}
|
||||
if (hasDropdown && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
nameDraft.value = nameSuggestions.value[nameActiveIndex.value].name;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
jumpTo(nameDraft.value);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
nameDraft.value = store.selectionRangeName.value;
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const sel = store.doc.selection;
|
||||
if (sel) store.setCellInput(sel.tableId, sel.anchor.col, sel.anchor.row, draft.value);
|
||||
}
|
||||
/** Lets the store insert a reference into this field when the user clicks a
|
||||
* grid cell mid-formula ("point mode") -- see store.pointClick/pointDrag. */
|
||||
function registerAsFormulaEditor() {
|
||||
const sel = store.doc.selection;
|
||||
if (!sel) return;
|
||||
store.registerFormulaEditor({
|
||||
tableId: sel.tableId,
|
||||
getState: () => ({
|
||||
text: draft.value,
|
||||
start: fieldEl.value?.selectionStart ?? draft.value.length,
|
||||
end: fieldEl.value?.selectionEnd ?? draft.value.length,
|
||||
}),
|
||||
replace: (start, end, value) => {
|
||||
draft.value = draft.value.slice(0, start) + value + draft.value.slice(end);
|
||||
nextTick(() => {
|
||||
fieldEl.value?.focus();
|
||||
fieldEl.value?.setSelectionRange(start + value.length, start + value.length);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
function onFocus() {
|
||||
focused.value = true;
|
||||
keyboardPoint.reset();
|
||||
registerAsFormulaEditor();
|
||||
}
|
||||
function onEnter() {
|
||||
commit();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
function onBlur() {
|
||||
focused.value = false;
|
||||
commit();
|
||||
store.registerFormulaEditor(null);
|
||||
autocomplete.close();
|
||||
}
|
||||
function onEscape() {
|
||||
draft.value = store.activeCellInput.value;
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
/** Actual typing (not a point-mode click inserting a reference programmatically)
|
||||
* ends the "next click replaces the last reference" window. */
|
||||
function onInput(e: Event) {
|
||||
store.noteFormulaTyped();
|
||||
keyboardPoint.reset();
|
||||
const el = e.target as HTMLTextAreaElement;
|
||||
autocomplete.update(draft.value, el.selectionStart ?? draft.value.length);
|
||||
}
|
||||
/** Accept the highlighted autocomplete suggestion (name + "(") via the same
|
||||
* replace() mechanism point-mode uses. */
|
||||
function applySuggestion(el: HTMLTextAreaElement) {
|
||||
const caret = el.selectionStart ?? draft.value.length;
|
||||
const rep = autocomplete.accept(draft.value, caret);
|
||||
if (!rep) return false;
|
||||
draft.value = draft.value.slice(0, rep.start) + rep.value + draft.value.slice(rep.end);
|
||||
autocomplete.close();
|
||||
nextTick(() => el.setSelectionRange(rep.start + rep.value.length, rep.start + rep.value.length));
|
||||
return true;
|
||||
}
|
||||
/** A mouse click on a dropdown entry (see FormulaSuggest's `@mousedown.prevent`
|
||||
* keeping focus in the textarea, so `fieldEl` is still the live editor). */
|
||||
function onPickSuggestion(i: number) {
|
||||
autocomplete.activeIndex.value = i;
|
||||
const el = fieldEl.value;
|
||||
if (el) applySuggestion(el);
|
||||
}
|
||||
/** Clicking inside the field to reposition the caret should also end that window. */
|
||||
function onMousedown() {
|
||||
store.noteFormulaTyped();
|
||||
keyboardPoint.reset();
|
||||
}
|
||||
/** F4: cycle relative -> absolute -> row-locked -> col-locked (Excel/Numbers
|
||||
* "Bezug sperren") -- for the reference at the caret, or every reference a
|
||||
* real text selection overlaps at once. */
|
||||
function onF4(el: HTMLTextAreaElement) {
|
||||
const start = el.selectionStart ?? draft.value.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
const res = cycleReferenceLocks(draft.value, start, end);
|
||||
if (!res) return;
|
||||
draft.value = res.text;
|
||||
nextTick(() => el.setSelectionRange(res.start, res.end));
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const el = e.target as HTMLTextAreaElement;
|
||||
// Function-name autocomplete takes priority over every other key below
|
||||
// while its dropdown is open (see useFormulaAutocomplete.ts).
|
||||
if (autocomplete.suggestions.value.length) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
autocomplete.move(e.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (applySuggestion(el)) return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
autocomplete.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Keyboard "point mode" -- see useKeyboardPointMode's own doc comment.
|
||||
const sel = store.doc.selection;
|
||||
if (sel) {
|
||||
const caret = el.selectionStart ?? draft.value.length;
|
||||
if (keyboardPoint.handleArrowKey(e, sel.tableId, sel.anchor, draft.value, caret)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Alt+Enter (Windows/Excel-365-Mac) or Cmd+Enter (a macOS-safe fallback --
|
||||
// Option is a compose key there, so `altKey` alone isn't fully reliable;
|
||||
// `e.code` sidesteps `key` being altered by that composition).
|
||||
const isEnter = e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter';
|
||||
if (isEnter && (e.altKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
const start = el.selectionStart ?? draft.value.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
draft.value = draft.value.slice(0, start) + '\n' + draft.value.slice(end);
|
||||
nextTick(() => el.setSelectionRange(start + 1, start + 1));
|
||||
return;
|
||||
}
|
||||
if (isEnter) {
|
||||
e.preventDefault();
|
||||
onEnter();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onEscape();
|
||||
} else if (e.key === 'F4') {
|
||||
e.preventDefault();
|
||||
onF4(el);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="formula-bar">
|
||||
<div class="name-box">
|
||||
<input
|
||||
ref="nameEl"
|
||||
v-model="nameDraft"
|
||||
class="cell-name"
|
||||
spellcheck="false"
|
||||
title="Name/Bezug eingeben und Enter drücken, um dorthin zu springen"
|
||||
@focus="onNameFocus"
|
||||
@blur="onNameBlur"
|
||||
@keydown="onNameKeydown"
|
||||
@input="onNameInput"
|
||||
/>
|
||||
<FormulaSuggest
|
||||
v-if="nameSuggestions.length"
|
||||
:items="nameSuggestions"
|
||||
:active-index="nameActiveIndex"
|
||||
@pick="onNamePick"
|
||||
/>
|
||||
</div>
|
||||
<span class="fx">fx</span>
|
||||
<div class="input-wrap">
|
||||
<textarea
|
||||
ref="fieldEl"
|
||||
v-model="draft"
|
||||
class="formula-input"
|
||||
spellcheck="false"
|
||||
rows="1"
|
||||
:placeholder="store.ui.readOnly ? 'Nur Lesen' : 'Wert oder =Formel'"
|
||||
:readonly="!!store.ui.readOnly"
|
||||
title="Ziehen an der unteren Kante, um mehr Zeilen zu zeigen"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
@input="onInput"
|
||||
@mousedown="onMousedown"
|
||||
/>
|
||||
<FormulaSuggest
|
||||
v-if="autocomplete.suggestions.value.length"
|
||||
:items="autocomplete.suggestions.value"
|
||||
:active-index="autocomplete.activeIndex.value"
|
||||
@pick="onPickSuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.formula-bar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
/* holds the FormulaSuggest dropdown, positioned relative to this -- same
|
||||
role as .input-wrap below, for the Name Box instead of the formula field */
|
||||
.name-box {
|
||||
position: relative;
|
||||
min-width: 128px;
|
||||
max-width: 128px;
|
||||
}
|
||||
.cell-name {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
margin: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 6px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cell-name:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.name-box {
|
||||
min-width: 64px;
|
||||
max-width: 64px;
|
||||
}
|
||||
.formula-bar {
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
.fx {
|
||||
margin-top: 6px;
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
/* holds the FormulaSuggest dropdown, positioned relative to this */
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
/* a plain <textarea> gives a free, native mouse-draggable resize handle
|
||||
(bottom-right corner) -- exactly the "erweiterbar/erhöhbar mit der Maus"
|
||||
ask -- and, unlike the old <input>, actually wraps a long formula instead
|
||||
of just scrolling it sideways, so growing the box has something to show. */
|
||||
.formula-input {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
max-height: 50vh;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
resize: vertical;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.formula-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Generic name/function autocomplete dropdown -- used both for a formula
|
||||
* editor's function-name autocomplete (see useFormulaAutocomplete.ts) and
|
||||
* the Name Box's table/sheet/named-range autocomplete (see document.ts's
|
||||
* `nameBoxSuggestions()`), anything shaped like {name, hint}. Purely
|
||||
* presentational -- positions itself just under whatever it's rendered
|
||||
* inside of (that parent needs `position: relative`); no caret-pixel
|
||||
* tracking.
|
||||
*/
|
||||
defineProps<{ items: { name: string; hint: string }[]; activeIndex: number }>();
|
||||
const emit = defineEmits<{ (e: 'pick', index: number): void }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="formula-suggest" role="listbox">
|
||||
<li
|
||||
v-for="(item, i) in items"
|
||||
:key="item.name"
|
||||
role="option"
|
||||
:aria-selected="i === activeIndex"
|
||||
:class="{ on: i === activeIndex }"
|
||||
@mousedown.prevent="emit('pick', i)"
|
||||
>
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="hint">{{ item.hint }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.formula-suggest {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
margin: 2px 0 0;
|
||||
padding: 4px;
|
||||
min-width: 220px;
|
||||
max-width: 420px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
font-size: 12px;
|
||||
}
|
||||
.formula-suggest li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.formula-suggest li.on {
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sign-in without a password: e-mail → a mail with a link and a 6-digit
|
||||
* code. Clicking the link signs in (in whatever browser opens it); typing
|
||||
* the code here signs in this browser. Both create the account on first use.
|
||||
*/
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useSession } from '../store/session';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean; reason?: string }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
|
||||
const session = useSession();
|
||||
const step = ref<'email' | 'code'>('email');
|
||||
const email = ref('');
|
||||
const code = ref('');
|
||||
const busy = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const emailInput = ref<HTMLInputElement | null>(null);
|
||||
const codeInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (o) => {
|
||||
if (!o) return;
|
||||
step.value = 'email';
|
||||
code.value = '';
|
||||
error.value = null;
|
||||
await nextTick();
|
||||
emailInput.value?.focus();
|
||||
},
|
||||
);
|
||||
|
||||
async function send() {
|
||||
if (!email.value.trim()) return;
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await session.requestLink(email.value.trim());
|
||||
step.value = 'code';
|
||||
await nextTick();
|
||||
codeInput.value?.focus();
|
||||
} catch (err) {
|
||||
error.value = (err as Error).message.includes('422')
|
||||
? 'Bitte eine gültige E-Mail-Adresse eingeben.'
|
||||
: (err as Error).message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
busy.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await session.verifyCode(email.value.trim(), code.value);
|
||||
} catch {
|
||||
error.value = 'Der Code stimmt nicht oder ist abgelaufen.';
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :open="open" title="Anmelden" @close="emit('close')">
|
||||
<template v-if="step === 'email'">
|
||||
<p v-if="reason" class="reason">{{ reason }}</p>
|
||||
<p class="lead">
|
||||
Mit einem Konto liegen deine Dokumente auf dem Server – auf allen Geräten verfügbar und teilbar.
|
||||
Du bekommst einen Anmeldelink per E-Mail, ein Passwort gibt es nicht.
|
||||
</p>
|
||||
<form @submit.prevent="send">
|
||||
<label class="row">
|
||||
E-Mail
|
||||
<input ref="emailInput" v-model="email" type="email" autocomplete="email" placeholder="name@beispiel.li" required />
|
||||
</label>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="lead">
|
||||
Wir haben eine Mail an <strong>{{ email }}</strong> geschickt. Klicke auf den Link darin – oder gib hier
|
||||
den 6-stelligen Code ein.
|
||||
</p>
|
||||
<form @submit.prevent="verify">
|
||||
<label class="row">
|
||||
Code
|
||||
<input
|
||||
ref="codeInput"
|
||||
v-model="code"
|
||||
class="code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="7"
|
||||
placeholder="123456"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
<button class="linkish" @click="step = 'email'">Andere Adresse / neuen Link anfordern</button>
|
||||
</template>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<template #footer>
|
||||
<button @click="emit('close')">Abbrechen</button>
|
||||
<button v-if="step === 'email'" class="primary" :disabled="busy || !email.trim()" @click="send">
|
||||
Link senden
|
||||
</button>
|
||||
<button v-else class="primary" :disabled="busy || code.replace(/\D/g, '').length !== 6" @click="verify">
|
||||
Anmelden
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lead,
|
||||
.reason {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reason {
|
||||
font-weight: 600;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.row input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 7px 9px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.code {
|
||||
letter-spacing: 0.25em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.linkish {
|
||||
margin-top: 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,336 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "▶ Makros" -- the exclusive UI for creating, viewing, editing, deleting
|
||||
* and test-running the document's macros (store/document.ts's runMacro(),
|
||||
* engine/parser.ts's parseMacroBlock()). Mirrors CustomFunctionsModal.vue's
|
||||
* list/edit-form shape, minus the parameter list and expression/block
|
||||
* toggle a macro doesn't have -- a macro is always the same write-capable
|
||||
* statement language, parameterless (triggered by a click, no arguments).
|
||||
*/
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import CodeReference from './CodeReference.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
const toasts = useToasts();
|
||||
|
||||
type Mode = 'list' | 'edit';
|
||||
const mode = ref<Mode>('list');
|
||||
const editingId = ref<string | null>(null);
|
||||
const form = reactive({ name: '', body: '' });
|
||||
const formError = ref<string | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) {
|
||||
mode.value = 'list';
|
||||
formError.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
form.name = '';
|
||||
form.body = '';
|
||||
formError.value = null;
|
||||
mode.value = 'edit';
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
const m = store.doc.macros.find((x) => x.id === id);
|
||||
if (!m) return;
|
||||
editingId.value = id;
|
||||
form.name = m.name;
|
||||
form.body = m.body;
|
||||
formError.value = null;
|
||||
mode.value = 'edit';
|
||||
}
|
||||
|
||||
function save() {
|
||||
const result = editingId.value
|
||||
? store.updateMacro(editingId.value, form.name, form.body)
|
||||
: store.addMacro(form.name, form.body);
|
||||
if (result && typeof result === 'object' && 'error' in result) {
|
||||
formError.value = result.error;
|
||||
return;
|
||||
}
|
||||
formError.value = null;
|
||||
mode.value = 'list';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
formError.value = null;
|
||||
mode.value = 'list';
|
||||
}
|
||||
|
||||
function removeMacro(id: string, name: string) {
|
||||
if (confirm(`Makro "${name}" löschen? Buttons, die es zugewiesen haben, verlieren die Zuweisung.`)) {
|
||||
store.deleteMacro(id);
|
||||
}
|
||||
}
|
||||
|
||||
// A macro can now involve a real network round-trip (GET/POST) and even a
|
||||
// blocking confirm() prompt -- guard against re-clicking "▶ Ausführen" on
|
||||
// the same row while its run is still in flight.
|
||||
const runningId = ref<string | null>(null);
|
||||
|
||||
async function runMacro(id: string) {
|
||||
if (runningId.value) return;
|
||||
runningId.value = id;
|
||||
try {
|
||||
const result = await store.runMacro(id);
|
||||
if (result.ok) toasts.success(result.message);
|
||||
else toasts.error(result.message);
|
||||
} finally {
|
||||
runningId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
/** Put a reference snippet at the caret (replacing any selection). */
|
||||
async function insertSnippet(text: string) {
|
||||
const el = bodyInput.value;
|
||||
const start = el?.selectionStart ?? form.body.length;
|
||||
const end = el?.selectionEnd ?? form.body.length;
|
||||
form.body = form.body.slice(0, start) + text + form.body.slice(end);
|
||||
await nextTick();
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(start + text.length, start + text.length);
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
:wide="mode === 'edit'"
|
||||
:title="mode === 'list' ? 'Makros' : editingId ? 'Makro bearbeiten' : 'Neues Makro'"
|
||||
@close="close"
|
||||
>
|
||||
<!-- ---- list view ---- -->
|
||||
<template v-if="mode === 'list'">
|
||||
<p v-if="!store.doc.macros.length" class="hint">
|
||||
Noch keine Makros definiert. Mit „+ Neues Makro" eines anlegen — danach über „▶" hier
|
||||
oder über einen Button auf der Fläche auslösbar.
|
||||
</p>
|
||||
<ul v-else class="fn-list">
|
||||
<li v-for="m in store.doc.macros" :key="m.id" class="fn-row">
|
||||
<code class="fn-sig">{{ m.name }}</code>
|
||||
<span class="fn-actions">
|
||||
<button
|
||||
class="mini run"
|
||||
title="Ausführen"
|
||||
:disabled="runningId === m.id"
|
||||
@click="runMacro(m.id)"
|
||||
>
|
||||
{{ runningId === m.id ? '… läuft' : '▶ Ausführen' }}
|
||||
</button>
|
||||
<button class="mini" @click="openEdit(m.id)">Bearb.</button>
|
||||
<button class="mini danger" title="Löschen" @click="removeMacro(m.id, m.name)">🗑</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- ---- edit / create form ---- -->
|
||||
<div v-else class="edit-layout">
|
||||
<div class="editor">
|
||||
<label class="row">
|
||||
Name
|
||||
<input v-model="form.name" type="text" placeholder="z. B. Quartalsabschluss" />
|
||||
</label>
|
||||
|
||||
<label class="row body-row">
|
||||
Aktionen
|
||||
<textarea
|
||||
ref="bodyInput"
|
||||
v-model="form.body"
|
||||
class="formula-input"
|
||||
rows="14"
|
||||
spellcheck="false"
|
||||
placeholder="INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A2; "Neu")"
|
||||
/>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Befehle rechts suchen, aufklappen für Beschreibung und Beispiel, mit
|
||||
<strong>Einfügen</strong> an der Cursorposition übernehmen. Wichtig: <strong>jeder
|
||||
Zellbezug nennt die Tabelle</strong> — <code>Umsatz::A1</code>, nie bloß <code>A1</code>.
|
||||
</p>
|
||||
|
||||
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
||||
</div>
|
||||
<CodeReference class="reference" context="macro" @insert="insertSnippet" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="mode === 'list'">
|
||||
<button class="primary" @click="openNew">+ Neues Makro</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button @click="cancelEdit">Abbrechen</button>
|
||||
<button class="primary" @click="save">Speichern</button>
|
||||
</template>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.hint code {
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.fn-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.fn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.fn-sig {
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fn-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini.run {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mini.run:hover {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.mini.danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.row input[type='text'] {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.body-row .formula-input {
|
||||
min-height: 30px;
|
||||
max-height: 40vh;
|
||||
}
|
||||
.formula-input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
resize: vertical;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.formula-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
.edit-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
/* side by side: fill the (viewport-capped) modal body and let the
|
||||
reference list scroll inside it instead of the whole body */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
/* stacked: let the modal body scroll as a whole */
|
||||
.edit-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.editor .body-row {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor .body-row .formula-input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 90px;
|
||||
max-height: none;
|
||||
}
|
||||
.editor .formula-input {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.form-error {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #dc2626;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "🏷 Namen" -- the exclusive UI for creating, viewing, editing and deleting
|
||||
* the document's named ranges (a document-wide name standing for a fixed
|
||||
* cell/range on a specific table, usable unqualified in any top-level
|
||||
* formula -- see src/store/types.ts's `NamedRange`). Mirrors
|
||||
* CustomFunctionsModal.vue's list/form structure.
|
||||
*/
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { looksLikeCell, parseA1, toA1 } from '../engine';
|
||||
import { useDocument } from '../store/document';
|
||||
import type { TableObject } from '../store/types';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
type Mode = 'list' | 'edit';
|
||||
const mode = ref<Mode>('list');
|
||||
const editingId = ref<string | null>(null);
|
||||
const form = reactive({ name: '', tableId: '', start: '', end: '' });
|
||||
const formError = ref<string | null>(null);
|
||||
|
||||
const allTables = computed(() =>
|
||||
store.doc.sheets.flatMap((s) =>
|
||||
s.objects
|
||||
.filter((o): o is TableObject => o.kind === 'table')
|
||||
.map((t) => ({ id: t.id, name: t.name, sheetName: s.name })),
|
||||
),
|
||||
);
|
||||
|
||||
function tableLabel(tableId: string): string {
|
||||
const t = allTables.value.find((x) => x.id === tableId);
|
||||
return t ? `${t.name} (${t.sheetName})` : '?';
|
||||
}
|
||||
|
||||
/** e.g. "B2" or "B2:B6" (single cell when start === end). */
|
||||
function rangeLabel(r: { start: { col: number; row: number }; end: { col: number; row: number } }): string {
|
||||
const a = toA1(r.start.col, r.start.row);
|
||||
const b = toA1(r.end.col, r.end.row);
|
||||
return a === b ? a : `${a}:${b}`;
|
||||
}
|
||||
|
||||
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
|
||||
// -progress never lingers into the next open.
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) {
|
||||
mode.value = 'list';
|
||||
formError.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
formError.value = null;
|
||||
// prefill from the current cell selection, if any -- "create name from
|
||||
// selection", same idea as Excel's own shortcut for this.
|
||||
const sel = store.doc.selection;
|
||||
if (sel) {
|
||||
const rect = store.selectionRects(sel)[0];
|
||||
form.tableId = sel.tableId;
|
||||
form.start = toA1(rect.c0, rect.r0);
|
||||
form.end = toA1(rect.c1, rect.r1);
|
||||
} else {
|
||||
form.tableId = allTables.value[0]?.id ?? '';
|
||||
form.start = '';
|
||||
form.end = '';
|
||||
}
|
||||
form.name = '';
|
||||
mode.value = 'edit';
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
const r = store.doc.namedRanges.find((x) => x.id === id);
|
||||
if (!r) return;
|
||||
editingId.value = id;
|
||||
form.name = r.name;
|
||||
form.tableId = r.tableId;
|
||||
form.start = toA1(r.start.col, r.start.row);
|
||||
form.end = toA1(r.end.col, r.end.row);
|
||||
formError.value = null;
|
||||
mode.value = 'edit';
|
||||
}
|
||||
|
||||
function save() {
|
||||
const startTok = form.start.trim();
|
||||
const endTok = form.end.trim() || startTok;
|
||||
if (!looksLikeCell(startTok)) {
|
||||
formError.value = `"${form.start}" ist kein gültiger Zellbezug.`;
|
||||
return;
|
||||
}
|
||||
if (!looksLikeCell(endTok)) {
|
||||
formError.value = `"${form.end}" ist kein gültiger Zellbezug.`;
|
||||
return;
|
||||
}
|
||||
const start = parseA1(startTok);
|
||||
const end = parseA1(endTok);
|
||||
const result = editingId.value
|
||||
? store.updateNamedRange(editingId.value, form.name, form.tableId, start, end)
|
||||
: store.addNamedRange(form.name, form.tableId, start, end);
|
||||
if (result && typeof result === 'object' && 'error' in result) {
|
||||
formError.value = result.error;
|
||||
return;
|
||||
}
|
||||
formError.value = null;
|
||||
mode.value = 'list';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
formError.value = null;
|
||||
mode.value = 'list';
|
||||
}
|
||||
|
||||
function removeRange(id: string, name: string) {
|
||||
if (confirm(`Name "${name}" löschen? Formeln, die ihn verwenden, zeigen danach #NAME?.`)) {
|
||||
store.deleteNamedRange(id);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
:title="mode === 'list' ? 'Benannte Bereiche' : editingId ? 'Namen bearbeiten' : 'Neuer Name'"
|
||||
@close="close"
|
||||
>
|
||||
<!-- ---- list view ---- -->
|
||||
<template v-if="mode === 'list'">
|
||||
<p v-if="!store.doc.namedRanges.length" class="hint">
|
||||
Noch keine benannten Bereiche definiert. Mit „+ Neuer Name" einen anlegen und danach
|
||||
wie <code>=Umsatz_Q1</code> in jeder Formel verwenden, ohne Tabellen-Qualifier.
|
||||
</p>
|
||||
<ul v-else class="nr-list">
|
||||
<li v-for="r in store.doc.namedRanges" :key="r.id" class="nr-row">
|
||||
<code class="nr-sig">
|
||||
{{ r.name }} → {{ tableLabel(r.tableId) }}!{{ rangeLabel(r) }}
|
||||
</code>
|
||||
<span class="nr-actions">
|
||||
<button class="mini" @click="openEdit(r.id)">Bearb.</button>
|
||||
<button class="mini danger" title="Löschen" @click="removeRange(r.id, r.name)">🗑</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- ---- edit / create form ---- -->
|
||||
<template v-else>
|
||||
<label class="row">
|
||||
Name
|
||||
<input v-model="form.name" type="text" placeholder="z. B. Umsatz_Q1" />
|
||||
</label>
|
||||
|
||||
<label class="row">
|
||||
Tabelle
|
||||
<select v-model="form.tableId">
|
||||
<option v-for="t in allTables" :key="t.id" :value="t.id">
|
||||
{{ t.name }} ({{ t.sheetName }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="row range-row">
|
||||
<span class="range-label">Bereich</span>
|
||||
<div class="range-fields">
|
||||
<input v-model="form.start" type="text" placeholder="B2" class="cell-input" />
|
||||
<span class="range-sep">:</span>
|
||||
<input v-model="form.end" type="text" placeholder="B6 (leer = nur B2)" class="cell-input" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
Eine leere zweite Zelle bedeutet „nur die erste Zelle" (kein Bereich). Der Name ist
|
||||
danach in jeder Formel unqualifiziert nutzbar, z. B. <code>=SUM(Umsatz_Q1)</code> oder
|
||||
<code>=Umsatz_Q1 + 1</code> für eine einzelne Zelle. Er überlebt eine spätere Umbenennung
|
||||
der Tabelle; wird die Tabelle gelöscht, zeigen Formeln, die den Namen nutzen, danach
|
||||
<code>#NAME?</code>.
|
||||
</p>
|
||||
|
||||
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="mode === 'list'">
|
||||
<button class="primary" @click="openNew">+ Neuer Name</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button @click="cancelEdit">Abbrechen</button>
|
||||
<button class="primary" @click="save">Speichern</button>
|
||||
</template>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.hint code {
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.nr-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.nr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.nr-sig {
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.nr-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini.danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.row input[type='text'],
|
||||
.row select {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.range-fields {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.cell-input {
|
||||
width: 120px;
|
||||
}
|
||||
.range-sep {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.form-error {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #dc2626;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,764 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Left inspector drawer, opened from the gear button next to the title.
|
||||
* Gathers every important setting in one place: the selected object (table
|
||||
* sizing / frozen rows & columns / position, or chart), the active sheet, and
|
||||
* document-wide options (theme, CSV default, reset), plus the canvas zoom.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
import { NUMBER_LOCALES, NUM_FORMATS } from '../store/format';
|
||||
import type { CellFormat, HAlign } from '../store/types';
|
||||
import { DEFAULT_GRID_SIZE, MIN_GRID_SIZE, MAX_GRID_SIZE } from '../store/types';
|
||||
import ToolbarIcon from './ToolbarIcon.vue';
|
||||
|
||||
const store = useDocument();
|
||||
const { mode: themeMode } = useTheme();
|
||||
|
||||
const open = computed(() => store.ui.settingsOpen);
|
||||
const obj = computed(() => store.activeObject.value);
|
||||
const table = computed(() =>
|
||||
obj.value?.kind === 'table' && obj.value.view ? obj.value : null,
|
||||
);
|
||||
const chart = computed(() => (obj.value?.kind === 'chart' ? obj.value : null));
|
||||
const text = computed(() => (obj.value?.kind === 'text' ? obj.value : null));
|
||||
const button = computed(() => (obj.value?.kind === 'button' ? obj.value : null));
|
||||
const sheet = computed(() => store.activeSheet.value);
|
||||
|
||||
const num = (e: Event) => Number((e.target as HTMLInputElement).value);
|
||||
const str = (e: Event) => (e.target as HTMLInputElement | HTMLSelectElement).value;
|
||||
const chk = (e: Event) => (e.target as HTMLInputElement).checked;
|
||||
|
||||
const tableList = computed(() =>
|
||||
store.doc.sheets.flatMap((s) => s.objects).filter((o) => o.kind === 'table'),
|
||||
);
|
||||
|
||||
// ---- cell-selection formatting (only when a table cell range is selected) ---
|
||||
const sel = computed(() => store.doc.selection);
|
||||
const selFmt = computed<CellFormat>(() => store.selectionFormat.value);
|
||||
const hasCellSel = computed(() => !!sel.value && sel.value.tableId === table.value?.id);
|
||||
const selFocus = computed(() => sel.value?.focus ?? { col: 0, row: 0 });
|
||||
const focusColW = computed(() =>
|
||||
table.value ? (table.value.colWidths[selFocus.value.col] ?? 104) : 104,
|
||||
);
|
||||
const focusRowH = computed(() =>
|
||||
table.value ? store.rowHeightOf(table.value.id, selFocus.value.row) : 26,
|
||||
);
|
||||
function applyFmt(patch: Partial<Record<keyof CellFormat, unknown>>) {
|
||||
store.applySelectionFormat(patch); // applies to every rectangle of the selection
|
||||
}
|
||||
function setAlign(a: HAlign) {
|
||||
applyFmt({ align: selFmt.value.align === a ? null : a });
|
||||
}
|
||||
|
||||
function close() {
|
||||
store.ui.settingsOpen = false;
|
||||
}
|
||||
function resetDoc() {
|
||||
if (confirm('Vorbefülltes Dokument wiederherstellen? Alle Änderungen gehen verloren.')) {
|
||||
store.resetToSeed();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="drawer" :class="{ open }" aria-label="Einstellungen">
|
||||
<header>
|
||||
<h2>Einstellungen</h2>
|
||||
<button class="x" title="Schließen" @click="close">×</button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<!-- ===================== selected object ===================== -->
|
||||
<section>
|
||||
<h3>Objekt</h3>
|
||||
|
||||
<template v-if="table">
|
||||
<label class="f">
|
||||
<span>Name</span>
|
||||
<input :value="table.name" @change="store.renameObject(table.id, str($event))" />
|
||||
</label>
|
||||
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Zeilen</span>
|
||||
<input type="number" min="1" :value="table.rows" @change="store.setTableRows(table.id, num($event))" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Spalten</span>
|
||||
<input type="number" min="1" :value="table.cols" @change="store.setTableCols(table.id, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="f">
|
||||
<span>Darstellung</span>
|
||||
<select :value="table.view.mode" @change="store.setTableView(table.id, { mode: str($event) === 'fixed' ? 'fixed' : 'auto' })">
|
||||
<option value="auto">Automatisch – alle Zeilen zeigen</option>
|
||||
<option value="fixed">Feste Größe – scrollbar</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<template v-if="table.view.mode === 'fixed'">
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Breite (px)</span>
|
||||
<input type="number" min="200" step="10" :value="table.view.width" @change="store.setTableView(table.id, { width: num($event) })" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Höhe (px)</span>
|
||||
<input type="number" min="120" step="10" :value="table.view.height" @change="store.setTableView(table.id, { height: num($event) })" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Fixierte Kopfzeilen</span>
|
||||
<input type="number" min="0" :max="table.rows - 1" :value="table.view.frozenRows" @change="store.setTableView(table.id, { frozenRows: num($event) })" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Fixierte Spalten</span>
|
||||
<input type="number" min="0" :max="table.cols - 1" :value="table.view.frozenCols" @change="store.setTableView(table.id, { frozenCols: num($event) })" />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Position X</span>
|
||||
<input type="number" min="0" step="4" :value="table.x" @change="store.moveObject(table.id, num($event), table.y)" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Position Y</span>
|
||||
<input type="number" min="0" step="4" :value="table.y" @change="store.moveObject(table.id, table.x, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="f">
|
||||
<span>Zahlenformat (Locale)</span>
|
||||
<select :value="table.locale ?? ''" @change="store.setObjectLocale(table.id, str($event) || null)">
|
||||
<option value="">— vom Blatt / Dokument</option>
|
||||
<option v-for="l in NUMBER_LOCALES" :key="l.id" :value="l.id">{{ l.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="f">
|
||||
<span>Zeilenhöhe – Standard (px)</span>
|
||||
<input type="number" min="14" max="240" :value="table.rowHeight ?? 26" @change="store.setTableRowHeight(table.id, num($event))" />
|
||||
</label>
|
||||
|
||||
<label class="f">
|
||||
<span>Tabellen-Hintergrund</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="table.bg ?? '#ffffff'" @input="store.setObjectBg(table.id, str($event))" />
|
||||
<button class="mini" title="Standard" @click="store.setObjectBg(table.id, null)">↺</button>
|
||||
<em v-if="!table.bg">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Tabellen-Textfarbe</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="table.fg ?? '#1d1d1f'" @input="store.setObjectTextColor(table.id, str($event))" />
|
||||
<button class="mini" title="Standard" @click="store.setObjectTextColor(table.id, null)">↺</button>
|
||||
<em v-if="!table.fg">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<label class="f row-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="!!table.hideChromeUnfocused"
|
||||
@change="store.setTableHideChromeUnfocused(table.id, chk($event))"
|
||||
/>
|
||||
<span>Ohne Rahmen/Kopfzeilen, wenn nicht ausgewählt</span>
|
||||
</label>
|
||||
|
||||
<!-- ---- current cell selection ---- -->
|
||||
<div v-if="hasCellSel" class="subsection">
|
||||
<h4>
|
||||
Auswahl · {{ store.activeCellName.value }}
|
||||
<template v-if="store.selectionAggregate.value.cells > 1">
|
||||
· {{ store.selectionAggregate.value.cells }} Zellen
|
||||
</template>
|
||||
</h4>
|
||||
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Spaltenbreite (px)</span>
|
||||
<input type="number" min="24" max="800" :value="focusColW" @change="store.setColWidth(table.id, selFocus.col, num($event))" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Zeilenhöhe (px)</span>
|
||||
<input type="number" min="14" max="240" :value="focusRowH" @change="store.setRowHeight(table.id, selFocus.row, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="f">
|
||||
<span>Ausrichtung</span>
|
||||
<span class="seg">
|
||||
<button :class="{ on: selFmt.align === 'left' }" @click="setAlign('left')">links</button>
|
||||
<button :class="{ on: selFmt.align === 'center' }" @click="setAlign('center')">mitte</button>
|
||||
<button :class="{ on: selFmt.align === 'right' }" @click="setAlign('right')">rechts</button>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="f">
|
||||
<span>Zahlenformat</span>
|
||||
<select :value="selFmt.numFmt ?? 'auto'" @change="applyFmt({ numFmt: str($event) })">
|
||||
<option v-for="nfmt in NUM_FORMATS" :key="nfmt.id" :value="nfmt.id">{{ nfmt.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="f row-inline">
|
||||
<button class="tgl" :class="{ on: selFmt.bold }" @click="applyFmt({ bold: !selFmt.bold })"><b>F</b></button>
|
||||
<button class="tgl" :class="{ on: selFmt.italic }" @click="applyFmt({ italic: !selFmt.italic })"><i>K</i></button>
|
||||
</label>
|
||||
|
||||
<label class="f">
|
||||
<span>Zell-Textfarbe</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="selFmt.color ?? '#1d1d1f'" @input="applyFmt({ color: str($event) })" />
|
||||
<button class="mini" title="Standard" @click="applyFmt({ color: null })">↺</button>
|
||||
</span>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Zell-Füllfarbe</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="selFmt.bg ?? '#ffffff'" @input="applyFmt({ bg: str($event) })" />
|
||||
<button class="mini" title="Standard" @click="applyFmt({ bg: null })">↺</button>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button @click="store.clearSelectionFormat()">Formatierung entfernen</button>
|
||||
</div>
|
||||
|
||||
<button class="danger" @click="store.deleteObject(table.id)">Tabelle löschen</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="chart">
|
||||
<label class="f">
|
||||
<span>Name</span>
|
||||
<input :value="chart.name" @change="store.renameObject(chart.id, str($event))" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Ansicht</span>
|
||||
<select :value="chart.view" @change="store.setChartView(chart.id, str($event) === 'line' ? 'line' : 'bar')">
|
||||
<option value="bar">Balken</option>
|
||||
<option value="line">Linie</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Quelltabelle</span>
|
||||
<select :value="chart.sourceTableId ?? ''" @change="store.setChartSource(chart.id, str($event))">
|
||||
<option v-for="t in tableList" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Breite (px)</span>
|
||||
<input type="number" min="200" step="10" :value="chart.width" @change="store.resizeChart(chart.id, num($event), chart.height)" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Höhe (px)</span>
|
||||
<input type="number" min="140" step="10" :value="chart.height" @change="store.resizeChart(chart.id, chart.width, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Position X</span>
|
||||
<input type="number" min="0" step="4" :value="chart.x" @change="store.moveObject(chart.id, num($event), chart.y)" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Position Y</span>
|
||||
<input type="number" min="0" step="4" :value="chart.y" @change="store.moveObject(chart.id, chart.x, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="f">
|
||||
<span>Zahlenformat (Locale)</span>
|
||||
<select :value="chart.locale ?? ''" @change="store.setObjectLocale(chart.id, str($event) || null)">
|
||||
<option value="">— vom Blatt / Dokument</option>
|
||||
<option v-for="l in NUMBER_LOCALES" :key="l.id" :value="l.id">{{ l.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Hintergrund</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="chart.bg ?? '#ffffff'" @input="store.setObjectBg(chart.id, str($event))" />
|
||||
<button class="mini" title="Standard" @click="store.setObjectBg(chart.id, null)">↺</button>
|
||||
<em v-if="!chart.bg">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Text-/Achsenfarbe</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="chart.fg ?? '#6e6e73'" @input="store.setObjectTextColor(chart.id, str($event))" />
|
||||
<button class="mini" title="Standard" @click="store.setObjectTextColor(chart.id, null)">↺</button>
|
||||
<em v-if="!chart.fg">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<button class="danger" @click="store.deleteObject(chart.id)">Diagramm löschen</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="text">
|
||||
<label class="f">
|
||||
<span>Name</span>
|
||||
<input :value="text.name" @change="store.renameObject(text.id, str($event))" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Text</span>
|
||||
<textarea rows="4" :value="text.text" @change="store.setTextContent(text.id, str($event))" />
|
||||
</label>
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Schriftgröße (px)</span>
|
||||
<input type="number" min="9" max="96" :value="text.fontSize" @change="store.setTextFormat(text.id, { fontSize: num($event) })" />
|
||||
</label>
|
||||
<label class="f row-inline" style="align-self: end; margin-bottom: 12px">
|
||||
<button class="tgl" :class="{ on: text.bold }" @click="store.setTextFormat(text.id, { bold: !text.bold })"><b>F</b></button>
|
||||
<button class="tgl" :class="{ on: text.italic }" @click="store.setTextFormat(text.id, { italic: !text.italic })"><i>K</i></button>
|
||||
</label>
|
||||
</div>
|
||||
<label class="f">
|
||||
<span>Ausrichtung</span>
|
||||
<span class="seg">
|
||||
<button :class="{ on: text.align === 'left' }" @click="store.setTextFormat(text.id, { align: 'left' })">links</button>
|
||||
<button :class="{ on: text.align === 'center' }" @click="store.setTextFormat(text.id, { align: 'center' })">mitte</button>
|
||||
<button :class="{ on: text.align === 'right' }" @click="store.setTextFormat(text.id, { align: 'right' })">rechts</button>
|
||||
</span>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Textfarbe</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="text.color ?? '#1d1d1f'" @input="store.setObjectTextColor(text.id, str($event))" />
|
||||
<button class="mini" title="Standard" @click="store.setObjectTextColor(text.id, null)">↺</button>
|
||||
<em v-if="!text.color">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Hintergrund</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="text.bg ?? '#ffffff'" @input="store.setObjectBg(text.id, str($event))" />
|
||||
<button class="mini" title="Ohne" @click="store.setObjectBg(text.id, null)">↺</button>
|
||||
<em v-if="!text.bg">ohne</em>
|
||||
</span>
|
||||
</label>
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Breite (px)</span>
|
||||
<input type="number" min="80" step="10" :value="text.width" @change="store.resizeText(text.id, num($event), text.height)" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Höhe (px)</span>
|
||||
<input type="number" min="32" step="10" :value="text.height" @change="store.resizeText(text.id, text.width, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Position X</span>
|
||||
<input type="number" min="0" step="4" :value="text.x" @change="store.moveObject(text.id, num($event), text.y)" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Position Y</span>
|
||||
<input type="number" min="0" step="4" :value="text.y" @change="store.moveObject(text.id, text.x, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<button class="danger" @click="store.deleteObject(text.id)">Textfeld löschen</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="button">
|
||||
<label class="f">
|
||||
<span>Name</span>
|
||||
<input :value="button.name" @change="store.renameObject(button.id, str($event))" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Beschriftung</span>
|
||||
<input :value="button.label" @change="store.setButtonLabel(button.id, str($event))" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Makro</span>
|
||||
<select :value="button.macroId ?? ''" @change="store.setButtonMacroId(button.id, str($event) || null)">
|
||||
<option value="">— keins —</option>
|
||||
<option v-for="m in store.doc.macros" :key="m.id" :value="m.id">{{ m.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="store.doc.macros.length === 0" class="muted">
|
||||
Noch kein Makro vorhanden — über „▶ Makros" in der Symbolleiste anlegen.
|
||||
</p>
|
||||
<label class="f">
|
||||
<span>Textfarbe</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="button.fg ?? '#0a84ff'" @input="store.setObjectTextColor(button.id, str($event))" />
|
||||
<button class="mini" title="Standard" @click="store.setObjectTextColor(button.id, null)">↺</button>
|
||||
<em v-if="!button.fg">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Hintergrund</span>
|
||||
<span class="color">
|
||||
<input type="color" :value="button.bg ?? '#e8f2ff'" @input="store.setObjectBg(button.id, str($event))" />
|
||||
<button class="mini" title="Ohne" @click="store.setObjectBg(button.id, null)">↺</button>
|
||||
<em v-if="!button.bg">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Breite (px)</span>
|
||||
<input type="number" min="60" step="10" :value="button.width" @change="store.resizeButton(button.id, num($event), button.height)" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Höhe (px)</span>
|
||||
<input type="number" min="28" step="10" :value="button.height" @change="store.resizeButton(button.id, button.width, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<label class="f">
|
||||
<span>Position X</span>
|
||||
<input type="number" min="0" step="4" :value="button.x" @change="store.moveObject(button.id, num($event), button.y)" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Position Y</span>
|
||||
<input type="number" min="0" step="4" :value="button.y" @change="store.moveObject(button.id, button.x, num($event))" />
|
||||
</label>
|
||||
</div>
|
||||
<button class="danger" @click="store.deleteObject(button.id)">Button löschen</button>
|
||||
</template>
|
||||
|
||||
<p v-else class="muted">Ein Objekt auf der Fläche anklicken, um es hier zu bearbeiten.</p>
|
||||
</section>
|
||||
|
||||
<!-- ===================== sheet ===================== -->
|
||||
<section>
|
||||
<h3>Blatt</h3>
|
||||
<label class="f">
|
||||
<span>Name</span>
|
||||
<input :value="sheet.name" @change="store.renameSheet(sheet.id, str($event))" />
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Zahlenformat</span>
|
||||
<select :value="sheet.locale ?? ''" @change="store.setSheetLocale(sheet.id, str($event) || null)">
|
||||
<option value="">— vom Dokument ({{ store.doc.locale }})</option>
|
||||
<option v-for="l in NUMBER_LOCALES" :key="l.id" :value="l.id">{{ l.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="f">
|
||||
<span>Hintergrundfarbe</span>
|
||||
<span class="color">
|
||||
<input
|
||||
type="color"
|
||||
:value="sheet.background ?? '#ffffff'"
|
||||
@input="store.setSheetBackground(sheet.id, str($event))"
|
||||
/>
|
||||
<button class="mini" title="Auf Standard zurücksetzen" @click="store.setSheetBackground(sheet.id, null)">↺</button>
|
||||
<em v-if="!sheet.background">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Schriftfarbe</span>
|
||||
<span class="color">
|
||||
<input
|
||||
type="color"
|
||||
:value="sheet.foreground ?? '#1d1d1f'"
|
||||
@input="store.setSheetForeground(sheet.id, str($event))"
|
||||
/>
|
||||
<button class="mini" title="Auf Standard zurücksetzen" @click="store.setSheetForeground(sheet.id, null)">↺</button>
|
||||
<em v-if="!sheet.foreground">Design</em>
|
||||
</span>
|
||||
</label>
|
||||
<label class="f row-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="sheet.showGrid !== false"
|
||||
@change="store.setSheetShowGrid(sheet.id, chk($event))"
|
||||
/>
|
||||
<span>Raster einblenden</span>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Rasterweite ({{ sheet.gridSize ?? DEFAULT_GRID_SIZE }} px)</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="MIN_GRID_SIZE"
|
||||
:max="MAX_GRID_SIZE"
|
||||
:value="sheet.gridSize ?? DEFAULT_GRID_SIZE"
|
||||
title="Steuert auch, worauf Objekte beim Verschieben/Skalieren einrasten"
|
||||
@input="store.setSheetGridSize(sheet.id, num($event))"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="row-btns">
|
||||
<button @click="store.addSheet()">Neues Blatt</button>
|
||||
<button class="danger" :disabled="store.doc.sheets.length <= 1" @click="store.deleteSheet(sheet.id)">
|
||||
Blatt löschen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== view ===================== -->
|
||||
<section>
|
||||
<h3>Ansicht</h3>
|
||||
<label class="f">
|
||||
<span>Zoom (dieses Blatt) · {{ Math.round(store.activeZoom.value * 100) }}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min="40"
|
||||
max="200"
|
||||
step="5"
|
||||
:value="Math.round(store.activeZoom.value * 100)"
|
||||
@input="store.setZoom(num($event) / 100)"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- ===================== document ===================== -->
|
||||
<section>
|
||||
<h3>Dokument</h3>
|
||||
<label class="f">
|
||||
<span>Erscheinungsbild</span>
|
||||
<div class="seg">
|
||||
<button
|
||||
:class="{ on: themeMode === 'system' }"
|
||||
title="Auto (folgt dem System)"
|
||||
aria-label="Auto (folgt dem System)"
|
||||
@click="themeMode = 'system'"
|
||||
>
|
||||
<ToolbarIcon name="auto" />
|
||||
</button>
|
||||
<button
|
||||
:class="{ on: themeMode === 'light' }"
|
||||
title="Hell"
|
||||
aria-label="Hell"
|
||||
@click="themeMode = 'light'"
|
||||
>
|
||||
<ToolbarIcon name="sun" />
|
||||
</button>
|
||||
<button
|
||||
:class="{ on: themeMode === 'dark' }"
|
||||
title="Dunkel"
|
||||
aria-label="Dunkel"
|
||||
@click="themeMode = 'dark'"
|
||||
>
|
||||
<ToolbarIcon name="moon" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>Zahlenformat (Ländereinstellung)</span>
|
||||
<select :value="store.doc.locale" @change="store.setDocumentLocale(str($event))">
|
||||
<option v-for="l in NUMBER_LOCALES" :key="l.id" :value="l.id">{{ l.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="f">
|
||||
<span>CSV-Trennzeichen (Standard)</span>
|
||||
<select :value="store.doc.csvDelimiter" @change="store.setCsvDelimiter(str($event) === ';' ? ';' : ',')">
|
||||
<option value=",">Komma ,</option>
|
||||
<option value=";">Semikolon ;</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="danger" @click="resetDoc">Auf Beispiel zurücksetzen</button>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 320px;
|
||||
max-width: 86vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateX(-102%);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.drawer.open {
|
||||
transform: none;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
header h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.body {
|
||||
overflow-y: auto;
|
||||
padding: 4px 14px 20px;
|
||||
}
|
||||
section {
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.subsection {
|
||||
margin: 6px 0 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.subsection h4 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tgl {
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tgl.on {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.f textarea {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 6px 8px;
|
||||
resize: vertical;
|
||||
}
|
||||
.f {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.f input,
|
||||
.f select {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.f input[type='range'] {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
.f.row-inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.f.row-inline input {
|
||||
width: auto;
|
||||
}
|
||||
.color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.color input[type='color'] {
|
||||
width: 42px;
|
||||
height: 28px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.color .mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.color .mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.color em {
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.row-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: 7px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.danger:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.seg button {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.seg button.on {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "Teilen" for the open document. Sharing needs the document on the server,
|
||||
* which needs an account: a local document is uploaded first (after
|
||||
* signing in). The owner then manages links ("anyone with the link can
|
||||
* view / edit") and people (invited by e-mail, with a role).
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { api, type ShareInfo } from '../io/api';
|
||||
import { useLibrary } from '../store/library';
|
||||
import { useSession } from '../store/session';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'login'): void }>();
|
||||
|
||||
const library = useLibrary();
|
||||
const session = useSession();
|
||||
const toasts = useToasts();
|
||||
|
||||
const info = ref<ShareInfo | null>(null);
|
||||
const busy = ref(false);
|
||||
const inviteEmail = ref('');
|
||||
const inviteRole = ref<'viewer' | 'editor'>('editor');
|
||||
|
||||
const current = computed(() => library.state.current);
|
||||
const isServer = computed(() => current.value?.source === 'server');
|
||||
const isOwner = computed(() => isServer.value && library.currentRole.value === 'owner');
|
||||
const docPath = computed(() => `/documents/${current.value?.id}`);
|
||||
|
||||
async function load() {
|
||||
info.value = null;
|
||||
if (!isOwner.value) return;
|
||||
try {
|
||||
info.value = await api<ShareInfo>('GET', `${docPath.value}/share`);
|
||||
} catch (err) {
|
||||
toasts.error((err as Error).message);
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => [props.open, current.value?.id, library.currentRole.value] as const,
|
||||
([o]) => {
|
||||
if (o) void load();
|
||||
},
|
||||
);
|
||||
|
||||
async function call(method: string, path: string, body?: unknown) {
|
||||
busy.value = true;
|
||||
try {
|
||||
info.value = await api<ShareInfo>(method, `${docPath.value}/share${path}`, { body });
|
||||
return true;
|
||||
} catch (err) {
|
||||
toasts.error((err as Error).message);
|
||||
return false;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const linkFor = (role: 'viewer' | 'editor') => info.value?.links.find((l) => l.role === role) ?? null;
|
||||
const urlFor = (token: string) => `${location.origin}/d/${current.value?.id}#k=${token}`;
|
||||
|
||||
async function copy(token: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(urlFor(token));
|
||||
toasts.success('Link kopiert.');
|
||||
} catch {
|
||||
toasts.error('Kopieren nicht möglich – bitte den Link markieren und kopieren.');
|
||||
}
|
||||
}
|
||||
async function newLink(role: 'viewer' | 'editor', replace: boolean) {
|
||||
if (replace && !confirm('Neuen Link erzeugen? Der bisherige funktioniert dann nicht mehr.')) return;
|
||||
if (await call('POST', '/links', { role })) {
|
||||
const l = linkFor(role);
|
||||
if (l) await copy(l.token);
|
||||
}
|
||||
}
|
||||
async function revoke(id: number) {
|
||||
if (confirm('Link deaktivieren? Wer das Dokument nur über diesen Link hatte, verliert den Zugriff.')) {
|
||||
await call('DELETE', `/links/${id}`);
|
||||
}
|
||||
}
|
||||
async function invite() {
|
||||
const email = inviteEmail.value.trim();
|
||||
if (!email) return;
|
||||
if (await call('POST', '/invitations', { email, role: inviteRole.value })) {
|
||||
toasts.success(`Einladung an ${email} verschickt.`);
|
||||
inviteEmail.value = '';
|
||||
}
|
||||
}
|
||||
async function setRole(userId: number, role: string) {
|
||||
await call('PATCH', `/members/${userId}`, { role });
|
||||
}
|
||||
async function removeMember(userId: number, name: string) {
|
||||
if (confirm(`${name} den Zugriff entziehen?`)) await call('DELETE', `/members/${userId}`);
|
||||
}
|
||||
|
||||
async function uploadAndShare() {
|
||||
const id = current.value?.id;
|
||||
if (!id) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await library.upload([id]);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const LINK_ROWS = [
|
||||
{ role: 'viewer', label: 'Jeder mit dem Link kann ansehen' },
|
||||
{ role: 'editor', label: 'Jeder mit dem Link kann bearbeiten' },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :open="open" :title="`Teilen · ${library.currentTitle.value}`" wide @close="emit('close')">
|
||||
<!-- local document -->
|
||||
<template v-if="!isServer">
|
||||
<p v-if="!session.signedIn.value" class="lead">
|
||||
Dieses Dokument liegt nur in diesem Browser. Zum Teilen brauchst du ein Konto – danach wird es in dein
|
||||
Konto hochgeladen.
|
||||
</p>
|
||||
<p v-else class="lead">
|
||||
Dieses Dokument liegt nur in diesem Browser. Zum Teilen wird es in dein Konto hochgeladen (und hier aus
|
||||
dem Browser entfernt).
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- server document, not the owner -->
|
||||
<p v-else-if="!isOwner" class="lead">
|
||||
Dieses Dokument gehört {{ library.state.server?.owner_name }}. Nur der Besitzer kann es teilen.
|
||||
</p>
|
||||
|
||||
<!-- owner -->
|
||||
<template v-else-if="info">
|
||||
<h3>Link</h3>
|
||||
<ul class="rows">
|
||||
<li v-for="r in LINK_ROWS" :key="r.role" class="link-row">
|
||||
<span class="label">{{ r.label }}</span>
|
||||
<template v-if="linkFor(r.role)">
|
||||
<input class="url" readonly :value="urlFor(linkFor(r.role)!.token)" @focus="($event.target as HTMLInputElement).select()" />
|
||||
<span class="actions">
|
||||
<button class="mini" @click="copy(linkFor(r.role)!.token)">Kopieren</button>
|
||||
<button class="mini" :disabled="busy" @click="newLink(r.role, true)">Neu</button>
|
||||
<button class="mini danger" :disabled="busy" @click="revoke(linkFor(r.role)!.id)">Deaktivieren</button>
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="actions">
|
||||
<button class="mini" :disabled="busy" @click="newLink(r.role, false)">Link erstellen</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="hint">Links funktionieren auch ohne Konto.</p>
|
||||
|
||||
<h3>Personen</h3>
|
||||
<form class="invite" @submit.prevent="invite">
|
||||
<input v-model="inviteEmail" type="email" placeholder="E-Mail-Adresse" required />
|
||||
<select v-model="inviteRole">
|
||||
<option value="editor">kann bearbeiten</option>
|
||||
<option value="viewer">kann ansehen</option>
|
||||
</select>
|
||||
<button class="primary" type="submit" :disabled="busy || !inviteEmail.trim()">Einladen</button>
|
||||
</form>
|
||||
<ul class="rows">
|
||||
<li class="person">
|
||||
<span class="who">
|
||||
<strong>{{ session.state.user?.name }}</strong>
|
||||
<span class="dim">{{ session.state.user?.email }}</span>
|
||||
</span>
|
||||
<span class="dim">Besitzer</span>
|
||||
</li>
|
||||
<li v-for="m in info.members" :key="m.user_id" class="person">
|
||||
<span class="who">
|
||||
<strong>{{ m.name }}</strong>
|
||||
<span class="dim">{{ m.email }}{{ m.source === 'link' ? ' · über Link' : '' }}</span>
|
||||
</span>
|
||||
<select :value="m.role" :disabled="busy" @change="setRole(m.user_id, ($event.target as HTMLSelectElement).value)">
|
||||
<option value="editor">kann bearbeiten</option>
|
||||
<option value="viewer">kann ansehen</option>
|
||||
</select>
|
||||
<button class="mini danger" :disabled="busy" @click="removeMember(m.user_id, m.name)">Entfernen</button>
|
||||
</li>
|
||||
<li v-for="i in info.invitations" :key="i.email + i.expires_at" class="person">
|
||||
<span class="who">
|
||||
<strong>{{ i.email }}</strong>
|
||||
<span class="dim">eingeladen · {{ i.role === 'editor' ? 'kann bearbeiten' : 'kann ansehen' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-else class="lead">Lade …</p>
|
||||
|
||||
<template #footer>
|
||||
<button @click="emit('close')">Schließen</button>
|
||||
<button v-if="!isServer && !session.signedIn.value" class="primary" @click="emit('login')">Anmelden</button>
|
||||
<button v-else-if="!isServer" class="primary" :disabled="busy" @click="uploadAndShare">
|
||||
Hochladen und teilen
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
h3:not(:first-child) {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.link-row,
|
||||
.person {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.label {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.url {
|
||||
flex: 1 1 220px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 7px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.who {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
.invite {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.invite input {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.invite input,
|
||||
select {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini.danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The sheet canvas: a pan/scroll/zoom surface that hosts table and chart
|
||||
* objects. Empty-space click deselects; objects come to front on select.
|
||||
* Files dropped anywhere on the canvas are handed to the import pipeline.
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { DEFAULT_GRID_SIZE } from '../store/types';
|
||||
import CanvasObject from './CanvasObject.vue';
|
||||
import TheLogo from './TheLogo.vue';
|
||||
|
||||
const emit = defineEmits<{ (e: 'files', files: FileList): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
const sheet = computed(() => store.activeSheet.value);
|
||||
|
||||
const zoom = computed(() => store.activeZoom.value);
|
||||
const setZoom = store.setZoom;
|
||||
|
||||
const dropActive = ref(false);
|
||||
|
||||
/** Per-sheet appearance -> CSS custom properties scoped to this canvas. */
|
||||
const appearance = computed(() => {
|
||||
const s: Record<string, string> = {};
|
||||
if (sheet.value.background) s['--bg'] = sheet.value.background;
|
||||
if (sheet.value.foreground) {
|
||||
s['--text'] = sheet.value.foreground;
|
||||
s['--text-dim'] = sheet.value.foreground;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
const showGrid = computed(() => sheet.value.showGrid !== false);
|
||||
/** Also the snap increment CanvasObject.vue moves/resizes against -- see
|
||||
* its own doc comment. */
|
||||
const gridSize = computed(() => sheet.value.gridSize ?? DEFAULT_GRID_SIZE);
|
||||
/** Scaled by zoom (unlike the rest of `.canvas-scroll`, which isn't) so the
|
||||
* visible dots always land exactly where an object will actually snap --
|
||||
* otherwise a zoomed-in/out grid would visually lie about the snap. */
|
||||
const gridStyle = computed(() => {
|
||||
const px = gridSize.value * zoom.value;
|
||||
return { backgroundSize: `${px}px ${px}px` };
|
||||
});
|
||||
|
||||
/** Unscaled plane size: enclose every object with generous margin.
|
||||
* A `fixed`-viewport table only ever occupies its own `view.width`/
|
||||
* `view.height` on the canvas (that's the entire point of "feste Größe" --
|
||||
* a bounded, internally-scrolling box) regardless of how many rows/cols it
|
||||
* holds; only an `auto`-size table actually grows with its content, so only
|
||||
* that case falls back to the row/col-count estimate. */
|
||||
const plane = computed(() => {
|
||||
let w = 1600;
|
||||
let h = 1000;
|
||||
for (const o of sheet.value.objects) {
|
||||
let ow: number;
|
||||
let oh: number;
|
||||
if (o.kind === 'table' && o.view.mode === 'fixed') {
|
||||
ow = o.view.width;
|
||||
oh = o.view.height;
|
||||
} else {
|
||||
ow = o.kind === 'table' ? o.cols * 104 + 60 : o.width;
|
||||
oh = o.kind === 'table' ? o.rows * 28 + 60 : o.height;
|
||||
}
|
||||
w = Math.max(w, o.x + ow + 500);
|
||||
h = Math.max(h, o.y + oh + 400);
|
||||
}
|
||||
return { w, h };
|
||||
});
|
||||
|
||||
function onPlaneMouseDown(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('plane')) store.selectObject(null);
|
||||
}
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
e.preventDefault();
|
||||
setZoom(zoom.value - Math.sign(e.deltaY) * 0.1);
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const o = store.activeObject.value;
|
||||
if (!o) return;
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && o.kind === 'chart') {
|
||||
e.preventDefault();
|
||||
store.deleteObject(o.id);
|
||||
}
|
||||
}
|
||||
function onDrop(e: DragEvent) {
|
||||
dropActive.value = false;
|
||||
if (e.dataTransfer?.files?.length) emit('files', e.dataTransfer.files);
|
||||
}
|
||||
|
||||
function selectObj(id: string) {
|
||||
store.selectObject(id);
|
||||
store.bringToFront(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="canvas-wrap"
|
||||
:class="{ 'drawer-open': store.ui.settingsOpen, 'dep-open': store.ui.depGraph }"
|
||||
:style="appearance"
|
||||
>
|
||||
<div
|
||||
class="canvas-scroll"
|
||||
:class="{ 'no-grid': !showGrid }"
|
||||
:style="gridStyle"
|
||||
tabindex="0"
|
||||
@wheel="onWheel"
|
||||
@keydown="onKeydown"
|
||||
@dragover.prevent="dropActive = true"
|
||||
@dragleave="dropActive = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="zoom-layer" :style="{ width: plane.w * zoom + 'px', height: plane.h * zoom + 'px' }">
|
||||
<div
|
||||
class="plane"
|
||||
:style="{ width: plane.w + 'px', height: plane.h + 'px', transform: `scale(${zoom})` }"
|
||||
@mousedown="onPlaneMouseDown"
|
||||
>
|
||||
<CanvasObject
|
||||
v-for="o in sheet.objects"
|
||||
:key="o.id"
|
||||
:object="o"
|
||||
:zoom="zoom"
|
||||
:selected="store.doc.activeObjectId === o.id"
|
||||
@select="selectObj(o.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sheet.objects.length === 0" class="hint">
|
||||
<TheLogo word class="hint-logo" />
|
||||
<p>Leeres Blatt — über die Toolbar „Tabelle", „Diagramm" oder „Text" einfügen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dropActive" class="drop">Datei ablegen (.num .json .csv .xlsx)</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: margin-left 0.18s ease, margin-right 0.18s ease;
|
||||
}
|
||||
.canvas-wrap.drawer-open {
|
||||
margin-left: 320px;
|
||||
}
|
||||
.canvas-wrap.dep-open {
|
||||
margin-right: 380px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.canvas-wrap.drawer-open {
|
||||
margin-left: 0; /* drawer overlays on narrow screens */
|
||||
}
|
||||
.canvas-wrap.dep-open {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.canvas-scroll {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
background-color: var(--bg);
|
||||
/* grid dots use a fixed neutral so they read on any custom backdrop --
|
||||
background-size is set inline (gridStyle) from the sheet's gridSize */
|
||||
background-image:
|
||||
linear-gradient(rgba(128, 128, 128, 0.22) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(128, 128, 128, 0.22) 1px, transparent 1px);
|
||||
background-position: 0 0;
|
||||
outline: none;
|
||||
}
|
||||
.canvas-scroll.no-grid {
|
||||
background-image: none;
|
||||
}
|
||||
.plane {
|
||||
position: relative;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 44px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hint p {
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.hint-logo {
|
||||
height: 62px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.drop {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
padding: 14px 20px;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
z-index: 20;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
/** Numbers-style bottom tab bar. Native HTML5 drag & drop for reordering. */
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
const renaming = ref<string | null>(null);
|
||||
const renameDraft = ref('');
|
||||
const renameInput = ref<HTMLInputElement | null>(null);
|
||||
let dragIndex = -1;
|
||||
|
||||
function startRename(id: string, name: string) {
|
||||
renaming.value = id;
|
||||
renameDraft.value = name;
|
||||
nextTick(() => renameInput.value?.select());
|
||||
}
|
||||
function commitRename() {
|
||||
if (renaming.value) store.renameSheet(renaming.value, renameDraft.value);
|
||||
renaming.value = null;
|
||||
}
|
||||
function cancelRename() {
|
||||
renaming.value = null;
|
||||
}
|
||||
|
||||
function removeSheet(id: string, name: string, hasContent: boolean) {
|
||||
if (store.doc.sheets.length <= 1) return;
|
||||
if (hasContent && !confirm(`Blatt „${name}" mit allen Objekten löschen?`)) return;
|
||||
store.deleteSheet(id);
|
||||
}
|
||||
|
||||
function onDragStart(i: number) {
|
||||
dragIndex = i;
|
||||
}
|
||||
function onDrop(i: number) {
|
||||
if (dragIndex >= 0 && dragIndex !== i) store.moveSheet(dragIndex, i);
|
||||
dragIndex = -1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabs">
|
||||
<div class="tab-list">
|
||||
<div
|
||||
v-for="(s, i) in store.doc.sheets"
|
||||
:key="s.id"
|
||||
class="tab"
|
||||
:class="{ active: s.id === store.doc.activeSheetId }"
|
||||
draggable="true"
|
||||
@click="store.setActiveSheet(s.id)"
|
||||
@dblclick="startRename(s.id, s.name)"
|
||||
@dragstart="onDragStart(i)"
|
||||
@dragover.prevent
|
||||
@drop="onDrop(i)"
|
||||
>
|
||||
<input
|
||||
v-if="renaming === s.id"
|
||||
ref="renameInput"
|
||||
v-model="renameDraft"
|
||||
class="rename"
|
||||
@click.stop
|
||||
@blur="commitRename"
|
||||
@keydown.enter.prevent="commitRename"
|
||||
@keydown.esc.prevent="cancelRename"
|
||||
/>
|
||||
<template v-else>
|
||||
<span class="label">{{ s.name }}</span>
|
||||
<button
|
||||
v-if="store.doc.sheets.length > 1"
|
||||
class="close"
|
||||
title="Blatt löschen"
|
||||
@click.stop="removeSheet(s.id, s.name, s.objects.length > 0)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<button class="add" title="Blatt hinzufügen" @click="store.addSheet()">+</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 60px;
|
||||
}
|
||||
.tab-list {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
min-width: 0;
|
||||
}
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
}
|
||||
.tab:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
}
|
||||
.tab:hover .close,
|
||||
.tab.active .close {
|
||||
opacity: 1;
|
||||
}
|
||||
.close:hover {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
.rename {
|
||||
font: inherit;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
width: 96px;
|
||||
}
|
||||
.add {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.add:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { formatValue } from '../store/format';
|
||||
|
||||
const store = useDocument();
|
||||
const agg = computed(() => store.selectionAggregate.value);
|
||||
const fmt = (n: number) =>
|
||||
formatValue(Math.round(n * 1e6) / 1e6, store.activeLocale.value);
|
||||
|
||||
const zoom = computed(() => store.activeZoom.value);
|
||||
const setZoom = store.setZoom;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="status-bar">
|
||||
<span class="range">{{ store.selectionRangeName.value }}</span>
|
||||
<span v-if="agg.count > 0" class="metric">Summe <b>{{ fmt(agg.sum) }}</b></span>
|
||||
<span v-if="agg.count > 0" class="metric">Mittel <b>{{ fmt(agg.avg) }}</b></span>
|
||||
<span class="metric">Anzahl <b>{{ agg.count }}</b></span>
|
||||
<span class="metric dim">Zellen {{ agg.cells }}</span>
|
||||
<span class="divider" />
|
||||
<div class="zoom-ctl" title="Zoom (dieses Blatt)">
|
||||
<button @click="setZoom(zoom - 0.1)">−</button>
|
||||
<button class="fit" @click="setZoom(1)">{{ Math.round(zoom * 100) }}%</button>
|
||||
<button @click="setZoom(zoom + 0.1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* shares one row with SheetTabs.vue (see App.vue's .bottom-bar) -- pinned to
|
||||
the right, scrolls internally rather than wrapping if it ever runs out of
|
||||
room, so the combined row always stays a single line. */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 1 auto;
|
||||
gap: 4px 14px;
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.metric b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.metric.dim {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.range {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.divider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: var(--border);
|
||||
flex: none;
|
||||
}
|
||||
.zoom-ctl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: none;
|
||||
}
|
||||
.zoom-ctl button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.zoom-ctl button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.zoom-ctl button.fit {
|
||||
width: auto;
|
||||
padding: 0 7px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A formatted text box object. Formatting (size, bold, italic, alignment,
|
||||
* text/background colour) lives on the object and is edited in the inspector;
|
||||
* double-click here edits the text itself.
|
||||
*/
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
|
||||
const props = defineProps<{ objId: string }>();
|
||||
const store = useDocument();
|
||||
|
||||
const obj = computed(() => {
|
||||
const o = store.objectById(props.objId);
|
||||
return o && o.kind === 'text' ? o : undefined;
|
||||
});
|
||||
|
||||
const editing = ref(false);
|
||||
const draft = ref('');
|
||||
const area = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
function beginEdit() {
|
||||
if (!obj.value) return;
|
||||
draft.value = obj.value.text;
|
||||
editing.value = true;
|
||||
nextTick(() => area.value?.focus());
|
||||
}
|
||||
function commit() {
|
||||
if (obj.value) store.setTextContent(obj.value.id, draft.value);
|
||||
editing.value = false;
|
||||
}
|
||||
function cancel() {
|
||||
editing.value = false;
|
||||
}
|
||||
|
||||
const boxStyle = computed(() => {
|
||||
const o = obj.value;
|
||||
if (!o) return {};
|
||||
return {
|
||||
fontSize: o.fontSize + 'px',
|
||||
fontWeight: o.bold ? '600' : '400',
|
||||
fontStyle: o.italic ? 'italic' : 'normal',
|
||||
textAlign: o.align,
|
||||
color: o.color || 'var(--text)',
|
||||
background: o.bg || 'transparent',
|
||||
} as Record<string, string>;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="obj" class="textbox" :style="boxStyle" @dblclick="beginEdit">
|
||||
<textarea
|
||||
v-if="editing"
|
||||
ref="area"
|
||||
v-model="draft"
|
||||
class="edit"
|
||||
spellcheck="false"
|
||||
@blur="commit"
|
||||
@keydown.esc.prevent="cancel"
|
||||
@keydown.enter.exact.stop
|
||||
@mousedown.stop
|
||||
/>
|
||||
<div v-else class="view">{{ obj.text || 'Doppelklick zum Bearbeiten' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.textbox {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px 10px;
|
||||
overflow: auto;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.view {
|
||||
min-height: 100%;
|
||||
}
|
||||
.edit {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
outline: none;
|
||||
resize: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Numolus brand mark: three top-aligned, slightly overlapping pop-art squares
|
||||
* with a keyline. Fills are fixed brand colours; the keyline uses a token so it
|
||||
* stays visible in dark mode. Pass `word` to also render the "numolus" wordmark.
|
||||
*/
|
||||
defineProps<{ word?: boolean }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="logo"
|
||||
:class="{ 'has-word': word }"
|
||||
:viewBox="word ? '64 62 270 206' : '64 64 270 142'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
role="img"
|
||||
aria-label="Numolus"
|
||||
>
|
||||
<g stroke="var(--logo-keyline, #111)" stroke-width="6">
|
||||
<rect x="70" y="100" width="40" height="90" style="fill:rgb(255,212,0);"/>
|
||||
<rect x="115" y="100" width="65" height="90" style="fill:rgb(0,87,255);"/>
|
||||
<rect x="185" y="100" width="130" height="90" style="fill:rgb(232,56,79);"/>
|
||||
</g>
|
||||
<text
|
||||
v-if="word"
|
||||
x="72"
|
||||
y="255"
|
||||
font-size="46"
|
||||
font-weight="600"
|
||||
fill="currentColor"
|
||||
>
|
||||
numolus
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
.logo text {
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Small hand-drawn line-icon set for the toolbar (no icon library/font —
|
||||
* consistent with this project's "nothing off the shelf" approach). One
|
||||
* shared <svg> shell (24x24, stroke-only) with a name-keyed set of shapes,
|
||||
* since every icon needs the exact same viewBox/stroke setup and only the
|
||||
* inner geometry differs.
|
||||
*/
|
||||
export type ToolbarIconName =
|
||||
| 'settings'
|
||||
| 'undo'
|
||||
| 'redo'
|
||||
| 'table'
|
||||
| 'chart'
|
||||
| 'text'
|
||||
| 'tag'
|
||||
| 'button'
|
||||
| 'macro'
|
||||
| 'sun'
|
||||
| 'moon'
|
||||
| 'auto';
|
||||
|
||||
defineProps<{ name: ToolbarIconName }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="ti"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<template v-if="name === 'settings'">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<circle cx="9" cy="6" r="2" fill="currentColor" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<circle cx="15" cy="12" r="2" fill="currentColor" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
<circle cx="7" cy="18" r="2" fill="currentColor" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'undo'">
|
||||
<path d="M7 10 H15 a4 4 0 0 1 0 8 h-3" />
|
||||
<polyline points="7 10 11 6" />
|
||||
<polyline points="7 10 11 14" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'redo'">
|
||||
<path d="M17 10 H9 a4 4 0 0 0 0 8 h3" />
|
||||
<polyline points="17 10 13 6" />
|
||||
<polyline points="17 10 13 14" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'table'">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
<line x1="3" y1="16" x2="21" y2="16" />
|
||||
<line x1="9" y1="4" x2="9" y2="20" />
|
||||
<line x1="15" y1="4" x2="15" y2="20" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'chart'">
|
||||
<line x1="4" y1="20" x2="20" y2="20" />
|
||||
<rect x="6" y="13" width="3" height="7" />
|
||||
<rect x="11" y="9" width="3" height="11" />
|
||||
<rect x="16" y="5" width="3" height="15" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'text'">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<line x1="7" y1="9" x2="17" y2="9" />
|
||||
<line x1="7" y1="13" x2="17" y2="13" />
|
||||
<line x1="7" y1="17" x2="13" y2="17" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'tag'">
|
||||
<polygon points="4,5 14,5 20,11 14,17 4,17" />
|
||||
<circle cx="9" cy="9" r="1.6" fill="currentColor" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'button'">
|
||||
<rect x="3" y="7" width="18" height="10" rx="3" />
|
||||
<polygon points="10,10.5 10,13.5 13,12" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'macro'">
|
||||
<polygon points="6,4 20,12 6,20" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'sun'">
|
||||
<circle cx="12" cy="12" r="4.5" />
|
||||
<line x1="12" y1="2.5" x2="12" y2="5" />
|
||||
<line x1="12" y1="19" x2="12" y2="21.5" />
|
||||
<line x1="2.5" y1="12" x2="5" y2="12" />
|
||||
<line x1="19" y1="12" x2="21.5" y2="12" />
|
||||
<line x1="4.9" y1="4.9" x2="6.7" y2="6.7" />
|
||||
<line x1="17.3" y1="17.3" x2="19.1" y2="19.1" />
|
||||
<line x1="4.9" y1="19.1" x2="6.7" y2="17.3" />
|
||||
<line x1="17.3" y1="6.7" x2="19.1" y2="4.9" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'moon'">
|
||||
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4 6.6 6.6 0 0 0 20 14.5Z" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === 'auto'">
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 3.5 A8.5 8.5 0 0 1 12 20.5 Z" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ti {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Shown once after signing in when this browser still has local documents:
|
||||
* which of them should move into the account (server)? Uploaded ones are
|
||||
* removed from the browser. "Später" leaves everything as is -- uploading
|
||||
* stays possible from the document list.
|
||||
*/
|
||||
import { ref, watch } from 'vue';
|
||||
import { useLibrary } from '../store/library';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
|
||||
const library = useLibrary();
|
||||
const selected = ref<Set<string>>(new Set());
|
||||
const busy = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) selected.value = new Set(library.state.docs.map((d) => d.id));
|
||||
},
|
||||
);
|
||||
|
||||
function toggle(id: string) {
|
||||
const s = new Set(selected.value);
|
||||
if (s.has(id)) s.delete(id);
|
||||
else s.add(id);
|
||||
selected.value = s;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
busy.value = true;
|
||||
try {
|
||||
await library.upload([...selected.value]);
|
||||
emit('close');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :open="open" title="Dokumente in dein Konto übernehmen?" @close="emit('close')">
|
||||
<p class="lead">
|
||||
In diesem Browser liegen noch Dokumente. Ausgewählte werden in dein Konto hochgeladen – dann sind sie
|
||||
auf allen Geräten da und lassen sich teilen – und hier aus dem Browser entfernt.
|
||||
</p>
|
||||
<ul class="list">
|
||||
<li v-for="d in library.state.docs" :key="d.id">
|
||||
<label>
|
||||
<input type="checkbox" :checked="selected.has(d.id)" @change="toggle(d.id)" />
|
||||
{{ d.title }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<template #footer>
|
||||
<button @click="emit('close')">Später</button>
|
||||
<button class="primary" :disabled="busy || !selected.size" @click="run">
|
||||
{{ busy ? 'Lade hoch …' : `${selected.size} hochladen` }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lead {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.list li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.list label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Function-name autocomplete for a formula editor (see FormulaBar.vue and
|
||||
* TableGrid.vue's in-cell editor, which both wire this in the same way).
|
||||
*
|
||||
* Unlike useTheme.ts/useToasts.ts (module-level singletons), this factory
|
||||
* creates fresh, independent state per call -- each formula editor gets its
|
||||
* own dropdown/selection, since two editors are never showing suggestions
|
||||
* for the same formula at once.
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { currentIdentifierAtCaret } from '../engine';
|
||||
import { useDocument } from '../store/document';
|
||||
import type { FnSuggestion } from '../store/types';
|
||||
|
||||
export function useFormulaAutocomplete() {
|
||||
const store = useDocument();
|
||||
const suggestions = ref<FnSuggestion[]>([]);
|
||||
const activeIndex = ref(0);
|
||||
|
||||
/** Recompute the dropdown for the current buffer + caret -- call this
|
||||
* after every edit to the text (an `input` handler). */
|
||||
function update(text: string, caret: number) {
|
||||
const hit = currentIdentifierAtCaret(text, caret);
|
||||
suggestions.value = hit && hit.text ? store.functionSuggestions(hit.text) : [];
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
function close() {
|
||||
suggestions.value = [];
|
||||
}
|
||||
/** Move the highlighted suggestion by `delta` (wraps). No-op while closed. */
|
||||
function move(delta: number) {
|
||||
const n = suggestions.value.length;
|
||||
if (!n) return;
|
||||
activeIndex.value = (activeIndex.value + delta + n) % n;
|
||||
}
|
||||
/** The replacement the caller should apply via its own `replace(start, end,
|
||||
* value)` (the same one point-mode already uses) to accept the currently
|
||||
* highlighted suggestion -- `null` when no dropdown is open, so the
|
||||
* caller falls through to its normal Tab/Enter handling. Appends "(" ,
|
||||
* ready for arguments, same as Excel/Sheets' own autocomplete. */
|
||||
function accept(text: string, caret: number): { start: number; end: number; value: string } | null {
|
||||
if (!suggestions.value.length) return null;
|
||||
const hit = currentIdentifierAtCaret(text, caret);
|
||||
if (!hit) return null;
|
||||
return { start: hit.start, end: caret, value: suggestions.value[activeIndex.value].name + '(' };
|
||||
}
|
||||
|
||||
return { suggestions, activeIndex, update, close, move, accept };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Keyboard-driven counterpart to the mouse "point mode" already in
|
||||
* store.pointClick/pointDrag (click/drag a grid cell while editing a
|
||||
* formula to insert its reference): arrow keys pick a cell/range the same
|
||||
* way, without leaving the keyboard.
|
||||
*
|
||||
* Only takes over the arrow keys when the caret sits right after a token
|
||||
* that expects a new operand -- the leading '=', an operator, '(', or the
|
||||
* ';' argument separator -- exactly like Excel/Numbers decide the same
|
||||
* thing. Anywhere else (mid-identifier, mid-number, ...) arrow keys keep
|
||||
* moving the text caret as normal; editing an existing formula by hand
|
||||
* still works.
|
||||
*
|
||||
* First press of a fresh chain steps one cell from `home` (the cell being
|
||||
* edited) in the pressed direction. Further plain-arrow presses move that
|
||||
* single cell on; holding Shift instead grows a range from the chain's
|
||||
* first cell (its `anchor`) to wherever the focus has moved to, the same
|
||||
* "collapse on a plain move, grow from the anchor on a extending one"
|
||||
* convention TableGrid's own grid-cell navigate() already uses.
|
||||
*/
|
||||
import { useDocument } from '../store/document';
|
||||
|
||||
const ARROW_DELTA: Record<string, [number, number]> = {
|
||||
ArrowUp: [0, -1],
|
||||
ArrowDown: [0, 1],
|
||||
ArrowLeft: [-1, 0],
|
||||
ArrowRight: [1, 0],
|
||||
};
|
||||
|
||||
const OPERAND_BOUNDARY = new Set(['=', '+', '-', '*', '/', '^', '&', '<', '>', '(', ';']);
|
||||
|
||||
export function useKeyboardPointMode() {
|
||||
const store = useDocument();
|
||||
let anchor: { tableId: string; col: number; row: number } | null = null;
|
||||
let focus: { col: number; row: number } | null = null;
|
||||
|
||||
/** Ends the current chain (if any) -- call whenever the editor's text is
|
||||
* typed into directly, the caret is repositioned by a mouse click, or a
|
||||
* new edit session begins, exactly the same triggers that already reset
|
||||
* the mouse point-mode's own "replace on next click" window
|
||||
* (store.noteFormulaTyped()). */
|
||||
function reset() {
|
||||
anchor = null;
|
||||
focus = null;
|
||||
}
|
||||
|
||||
function canStartAt(text: string, caret: number): boolean {
|
||||
if (!text.startsWith('=')) return false;
|
||||
const prev = text[caret - 1];
|
||||
return prev !== undefined && OPERAND_BOUNDARY.has(prev);
|
||||
}
|
||||
|
||||
/** Handles one ArrowUp/Down/Left/Right press (optionally Shift-extended).
|
||||
* Returns true if it took over the key (caller must preventDefault +
|
||||
* stopPropagation); false if normal caret movement should happen
|
||||
* instead. `home` is the cell being edited. */
|
||||
function handleArrowKey(
|
||||
e: KeyboardEvent,
|
||||
tableId: string,
|
||||
home: { col: number; row: number },
|
||||
text: string,
|
||||
caret: number,
|
||||
): boolean {
|
||||
const delta = ARROW_DELTA[e.key];
|
||||
if (!delta) return false;
|
||||
const t = store.tableById(tableId);
|
||||
if (!t) return false;
|
||||
const clampC = (c: number) => Math.max(0, Math.min(t.cols - 1, c));
|
||||
const clampR = (r: number) => Math.max(0, Math.min(t.rows - 1, r));
|
||||
|
||||
if (!anchor || anchor.tableId !== tableId) {
|
||||
if (!canStartAt(text, caret)) return false;
|
||||
const target = { col: clampC(home.col + delta[0]), row: clampR(home.row + delta[1]) };
|
||||
anchor = { tableId, ...target };
|
||||
focus = target;
|
||||
store.pointClick(tableId, target.col, target.row);
|
||||
return true;
|
||||
}
|
||||
|
||||
const base = focus ?? anchor;
|
||||
const next = { col: clampC(base.col + delta[0]), row: clampR(base.row + delta[1]) };
|
||||
if (e.shiftKey) {
|
||||
focus = next;
|
||||
store.pointDrag(tableId, anchor.col, anchor.row, focus.col, focus.row);
|
||||
} else {
|
||||
anchor = { tableId, ...next };
|
||||
focus = next;
|
||||
store.pointClick(tableId, next.col, next.row);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return { handleArrowKey, reset };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ref, watchEffect } from 'vue';
|
||||
|
||||
type Mode = 'system' | 'light' | 'dark';
|
||||
|
||||
const KEY = 'numolus:theme';
|
||||
/** Key used before the rename to numolus -- still read as a fallback. */
|
||||
const LEGACY_KEY = 'numbers-study:theme';
|
||||
const stored =
|
||||
((localStorage.getItem(KEY) ?? localStorage.getItem(LEGACY_KEY)) as Mode | null) ??
|
||||
'system';
|
||||
const mode = ref<Mode>(stored);
|
||||
|
||||
watchEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (mode.value === 'system') root.removeAttribute('data-theme');
|
||||
else root.setAttribute('data-theme', mode.value);
|
||||
try {
|
||||
localStorage.setItem(KEY, mode.value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
/** True when the effective theme is dark (used to recolour Chart.js). */
|
||||
function isDarkNow(): boolean {
|
||||
if (mode.value === 'dark') return true;
|
||||
if (mode.value === 'light') return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
function cycle() {
|
||||
mode.value =
|
||||
mode.value === 'system' ? 'light' : mode.value === 'light' ? 'dark' : 'system';
|
||||
}
|
||||
return { mode, cycle, isDarkNow };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
kind: 'info' | 'success' | 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
const state = reactive<{ items: Toast[] }>({ items: [] });
|
||||
let seq = 0;
|
||||
|
||||
export function useToasts() {
|
||||
function push(kind: Toast['kind'], message: string, ttl = 4000) {
|
||||
const id = ++seq;
|
||||
state.items.push({ id, kind, message });
|
||||
if (ttl > 0) setTimeout(() => dismiss(id), ttl);
|
||||
return id;
|
||||
}
|
||||
function dismiss(id: number) {
|
||||
const i = state.items.findIndex((t) => t.id === id);
|
||||
if (i >= 0) state.items.splice(i, 1);
|
||||
}
|
||||
return {
|
||||
toasts: state.items,
|
||||
info: (m: string) => push('info', m),
|
||||
success: (m: string) => push('success', m),
|
||||
error: (m: string) => push('error', m, 6000),
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Publisher / company metadata.
|
||||
* Source: https://wicom.li (fetched 2026-08-29).
|
||||
*/
|
||||
export const WICOM = {
|
||||
name: 'wicom',
|
||||
legalName: 'wicom – Wild Karl-Heinz',
|
||||
descriptor: 'Webentwicklung & digitale Lösungen',
|
||||
tagline: 'Unsere Arbeiten – Ihr Erfolg',
|
||||
since: 1997,
|
||||
url: 'https://wicom.li',
|
||||
email: 'office@wicom.li',
|
||||
phone: '+41 79 446 2901',
|
||||
phoneHref: 'tel:+41794462901',
|
||||
address: {
|
||||
street: 'Birkenweg 6',
|
||||
city: 'FL-9490 Vaduz',
|
||||
country: 'Liechtenstein',
|
||||
},
|
||||
author: 'Karl-Heinz Wild',
|
||||
} as const;
|
||||
|
||||
/** One-line description of this app, used for `<meta name="description">` / OG. */
|
||||
export const APP_DESCRIPTION =
|
||||
'Numolus — browserbasierte Tabellenkalkulation nach dem Vorbild von Apple Numbers, ' +
|
||||
'mit selbst geschriebener Formel-Engine. wicom, Vaduz, FL.';
|
||||
|
||||
/**
|
||||
* Social-media profiles of wicom.
|
||||
* wicom.li lists none — add `{ label, url }` entries here and they appear in the
|
||||
* header and the "Über"-Dialog automatically.
|
||||
*/
|
||||
export const WICOM_SOCIAL: ReadonlyArray<{ label: string; url: string }> = [
|
||||
// { label: 'LinkedIn', url: 'https://www.linkedin.com/company/…' },
|
||||
// { label: 'GitHub', url: 'https://github.com/…' },
|
||||
// { label: 'X', url: 'https://x.com/…' },
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFormula, parseFunctionBlock, parseMacroBlock } from '../../engine';
|
||||
import { CODE_REFERENCE, matchesQuery, referenceFor } from '../codeReference';
|
||||
|
||||
describe('CODE_REFERENCE', () => {
|
||||
it('documents every macro write action', () => {
|
||||
const names = new Set(referenceFor('macro').map((e) => e.name));
|
||||
for (const a of [
|
||||
'SETVALUE', 'INSERTROW', 'DELETEROW', 'INSERTCOL', 'DELETECOL', 'ADDTABLE',
|
||||
'RENAMETABLE', 'DELETETABLE', 'ADDSHEET', 'RENAMESHEET', 'DELETESHEET',
|
||||
'GET', 'POST', 'JSONGET',
|
||||
]) {
|
||||
expect(names.has(a)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('every example (except "…" fragments) parses in each context it is listed for', () => {
|
||||
// the parameter names the function examples use; in a function body only
|
||||
// control-flow examples are whole statement sequences, the rest are
|
||||
// expressions (usable anywhere inside one)
|
||||
const params = ['bereich', 'umsatz', 'kosten'];
|
||||
for (const e of CODE_REFERENCE) {
|
||||
if (!e.example || e.example.includes('…')) continue;
|
||||
const ex = e.example;
|
||||
for (const ctx of e.contexts) {
|
||||
const parse =
|
||||
ctx === 'macro'
|
||||
? () => parseMacroBlock(ex)
|
||||
: ctx === 'block' && e.category === 'Ablauf'
|
||||
? () => parseFunctionBlock(ex, params)
|
||||
: () => parseFormula(ex, params);
|
||||
expect(parse, `${ctx} · ${e.name}: ${ex}`).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('function editors never offer write actions or network calls', () => {
|
||||
for (const ctx of ['block', 'expression'] as const) {
|
||||
const names = referenceFor(ctx).map((e) => e.name);
|
||||
for (const a of ['SETVALUE', 'INSERTROW', 'ADDSHEET', 'GET', 'POST', 'JSONGET']) {
|
||||
expect(names).not.toContain(a);
|
||||
}
|
||||
}
|
||||
// single formulas have no statements at all
|
||||
expect(referenceFor('expression').some((e) => e.category === 'Ablauf')).toBe(false);
|
||||
});
|
||||
|
||||
it('search matches synonyms and requires every word', () => {
|
||||
const hits = (q: string) => referenceFor('macro').filter((e) => matchesQuery(e, q)).map((e) => e.name);
|
||||
expect(hits('zeile einfügen')).toContain('INSERTROW');
|
||||
expect(hits('zeile einfügen')).not.toContain('DELETEROW');
|
||||
expect(hits('schleife')).toContain('FOR … IN … DO … END');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* The searchable command reference shown next to the macro and custom-
|
||||
* function editors (components/CodeReference.vue). Each entry lists the
|
||||
* editing contexts it applies to -- a macro, a "Mit Schleife" function body
|
||||
* or a single-formula function body. Hand-maintained alongside
|
||||
* engine/evaluator.ts's `execAction()` (the write actions), its GET/POST/
|
||||
* JSONGET handling and the statement grammar in engine/parser.ts -- the
|
||||
* formula-function entries are derived from `BUILTIN_SIGNATURES`, so a new
|
||||
* built-in shows up here automatically (with a generic description until
|
||||
* one is added to `FUNCTION_DESCRIPTIONS`).
|
||||
*/
|
||||
import { BUILTIN_SIGNATURES } from '../engine';
|
||||
|
||||
export type RefCategory =
|
||||
| 'Zellen'
|
||||
| 'Zeilen & Spalten'
|
||||
| 'Tabellen'
|
||||
| 'Blätter'
|
||||
| 'Ablauf'
|
||||
| 'Netzwerk & JSON'
|
||||
| 'Operatoren'
|
||||
| 'Formelfunktionen';
|
||||
|
||||
/** Which editor the reference is shown in. */
|
||||
export type RefContext = 'macro' | 'block' | 'expression';
|
||||
|
||||
export interface RefEntry {
|
||||
name: string;
|
||||
/** Editors this entry applies to. */
|
||||
contexts: RefContext[];
|
||||
category: RefCategory;
|
||||
/** Signature as shown in the list. */
|
||||
syntax: string;
|
||||
description: string;
|
||||
/** A complete, working example. */
|
||||
example?: string;
|
||||
/** What "Einfügen" puts into the editor. */
|
||||
snippet: string;
|
||||
/** Extra search terms (German synonyms etc.). */
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
export const REF_CATEGORIES: RefCategory[] = [
|
||||
'Zellen',
|
||||
'Zeilen & Spalten',
|
||||
'Tabellen',
|
||||
'Blätter',
|
||||
'Ablauf',
|
||||
'Netzwerk & JSON',
|
||||
'Operatoren',
|
||||
'Formelfunktionen',
|
||||
];
|
||||
|
||||
const ACTIONS: RefEntry[] = [
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'SETVALUE',
|
||||
category: 'Zellen',
|
||||
syntax: 'SETVALUE(Ziel; Wert)',
|
||||
description:
|
||||
'Schreibt einen Wert in eine Zelle. Ist das Ziel ein Bereich, erhält jede Zelle darin denselben Wert. Beginnt der Text mit „=", wird daraus eine echte Formel. Das Ziel muss die Tabelle nennen (Umsatz::A1, nie bloß A1).',
|
||||
example: 'SETVALUE(Umsatz::B2; Umsatz::B2 + 1)\nSETVALUE(Umsatz::F2; "=SUM(B2:E2)")',
|
||||
snippet: 'SETVALUE(Tabelle::A1; Wert)',
|
||||
keywords: 'schreiben setzen wert zelle bereich formel eintragen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'INSERTROW',
|
||||
category: 'Zeilen & Spalten',
|
||||
syntax: 'INSERTROW(Tabelle; Index)',
|
||||
description:
|
||||
'Fügt eine leere Zeile ein. Index zählt ab 0: 0 fügt vor der ersten Zeile ein. Formelbezüge im ganzen Dokument werden wie beim manuellen Einfügen angepasst.',
|
||||
example: 'INSERTROW("Umsatz"; 1)',
|
||||
snippet: 'INSERTROW("Tabelle"; 0)',
|
||||
keywords: 'zeile einfügen hinzufügen neu',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'DELETEROW',
|
||||
category: 'Zeilen & Spalten',
|
||||
syntax: 'DELETEROW(Tabelle; Index)',
|
||||
description: 'Löscht die Zeile mit dem Index (ab 0). Bezüge auf gelöschte Zellen werden zu #REF!.',
|
||||
example: 'DELETEROW("Umsatz"; 5)',
|
||||
snippet: 'DELETEROW("Tabelle"; 0)',
|
||||
keywords: 'zeile löschen entfernen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'INSERTCOL',
|
||||
category: 'Zeilen & Spalten',
|
||||
syntax: 'INSERTCOL(Tabelle; Index)',
|
||||
description: 'Fügt eine leere Spalte ein. Index zählt ab 0 (0 = vor Spalte A).',
|
||||
example: 'INSERTCOL("Umsatz"; 5)',
|
||||
snippet: 'INSERTCOL("Tabelle"; 0)',
|
||||
keywords: 'spalte einfügen hinzufügen neu',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'DELETECOL',
|
||||
category: 'Zeilen & Spalten',
|
||||
syntax: 'DELETECOL(Tabelle; Index)',
|
||||
description: 'Löscht die Spalte mit dem Index (ab 0, 0 = Spalte A).',
|
||||
example: 'DELETECOL("Umsatz"; 7)',
|
||||
snippet: 'DELETECOL("Tabelle"; 0)',
|
||||
keywords: 'spalte löschen entfernen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'ADDTABLE',
|
||||
category: 'Tabellen',
|
||||
syntax: 'ADDTABLE(Blatt; Name)',
|
||||
description: 'Legt auf dem Blatt eine neue, leere Tabelle mit diesem Namen an.',
|
||||
example: 'ADDTABLE("Analyse"; "Auswertung")',
|
||||
snippet: 'ADDTABLE("Blatt"; "Name")',
|
||||
keywords: 'tabelle neu anlegen erstellen hinzufügen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'RENAMETABLE',
|
||||
category: 'Tabellen',
|
||||
syntax: 'RENAMETABLE(Tabelle; NeuerName)',
|
||||
description: 'Benennt eine Tabelle um. Formeln, die sie nennen, werden dokumentweit mit umgeschrieben.',
|
||||
example: 'RENAMETABLE("Umsatz"; "Umsatz 2026")',
|
||||
snippet: 'RENAMETABLE("Tabelle"; "NeuerName")',
|
||||
keywords: 'tabelle umbenennen name ändern',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'DELETETABLE',
|
||||
category: 'Tabellen',
|
||||
syntax: 'DELETETABLE(Tabelle)',
|
||||
description: 'Löscht eine Tabelle. Bezüge darauf werden zu #REF!.',
|
||||
example: 'DELETETABLE("Blatt 2!Entwurf")',
|
||||
snippet: 'DELETETABLE("Tabelle")',
|
||||
keywords: 'tabelle löschen entfernen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'ADDSHEET',
|
||||
category: 'Blätter',
|
||||
syntax: 'ADDSHEET(Name)',
|
||||
description: 'Legt ein neues, leeres Blatt an. Ist der Name vergeben, wird eine Nummer angehängt.',
|
||||
example: 'ADDSHEET("Auswertung")',
|
||||
snippet: 'ADDSHEET("Name")',
|
||||
keywords: 'blatt neu anlegen erstellen hinzufügen sheet',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'RENAMESHEET',
|
||||
category: 'Blätter',
|
||||
syntax: 'RENAMESHEET(Blatt; NeuerName)',
|
||||
description: 'Benennt ein Blatt um.',
|
||||
example: 'RENAMESHEET("Blatt 2"; "Archiv")',
|
||||
snippet: 'RENAMESHEET("Blatt"; "NeuerName")',
|
||||
keywords: 'blatt umbenennen name ändern sheet',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'DELETESHEET',
|
||||
category: 'Blätter',
|
||||
syntax: 'DELETESHEET(Blatt)',
|
||||
description: 'Löscht ein Blatt samt Inhalt. Das letzte Blatt bleibt immer erhalten.',
|
||||
example: 'DELETESHEET("Archiv")',
|
||||
snippet: 'DELETESHEET("Blatt")',
|
||||
keywords: 'blatt löschen entfernen sheet',
|
||||
},
|
||||
];
|
||||
|
||||
const CONTROL_MACRO: RefEntry[] = [
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'Zuweisung',
|
||||
category: 'Ablauf',
|
||||
syntax: 'name = Ausdruck',
|
||||
description:
|
||||
'Speichert einen Wert unter einem Namen. Der Name bleibt für den restlichen Code sichtbar, auch nach dem Ende eines FOR/IF.',
|
||||
example: 'summe = SUM(Umsatz::B2:B6)\nSETVALUE(Umsatz::B8; summe)',
|
||||
snippet: 'name = Ausdruck',
|
||||
keywords: 'variable zuweisen speichern let',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'FOR … IN … DO … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'FOR x IN Bereich DO … END',
|
||||
description:
|
||||
'Wiederholt die Anweisungen für jede Zelle eines Bereichs; x ist dabei der jeweilige Zellbezug. Auch über ein JSON-Array (siehe GET).',
|
||||
example: 'FOR z IN Umsatz::B2:B6 DO\n SETVALUE(z; z * 1.1)\nEND',
|
||||
snippet: 'FOR x IN Tabelle::A1:A10 DO\n \nEND',
|
||||
keywords: 'schleife loop wiederholen jede zelle iteration',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'IF … THEN … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'IF Bedingung THEN … END',
|
||||
description: 'Führt die Anweisungen nur aus, wenn die Bedingung wahr ist. Beliebig verschachtelbar.',
|
||||
example: 'IF Umsatz::B2 > 1000 THEN\n SETVALUE(Umsatz::C2; "Ziel erreicht")\nEND',
|
||||
snippet: 'IF Bedingung THEN\n \nEND',
|
||||
keywords: 'wenn bedingung verzweigung falls',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'IF … THEN … ELSE … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'IF Bedingung THEN … ELSE … END',
|
||||
description: 'Wie IF, mit einem zweiten Zweig, der läuft, wenn die Bedingung falsch ist.',
|
||||
example: 'IF Umsatz::B2 > 0 THEN\n SETVALUE(Umsatz::C2; "Plus")\nELSE\n SETVALUE(Umsatz::C2; "Minus")\nEND',
|
||||
snippet: 'IF Bedingung THEN\n \nELSE\n \nEND',
|
||||
keywords: 'wenn sonst bedingung verzweigung',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'RETURN',
|
||||
category: 'Ablauf',
|
||||
syntax: 'RETURN "Meldung"',
|
||||
description:
|
||||
'Beendet das Makro sofort, auch mitten in einer Schleife. Der Text erscheint als Erfolgsmeldung statt „Makro ausgeführt.".',
|
||||
example: 'IF Umsatz::A2 = "" THEN RETURN "Nichts zu tun" END',
|
||||
snippet: 'RETURN "Fertig"',
|
||||
keywords: 'abbrechen beenden stop meldung',
|
||||
},
|
||||
];
|
||||
|
||||
const NETWORK: RefEntry[] = [
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'GET',
|
||||
category: 'Netzwerk & JSON',
|
||||
syntax: 'r = GET(URL)',
|
||||
description:
|
||||
'Ruft eine Adresse ab und speichert die JSON-Antwort in r. Nur als ganze rechte Seite einer Zuweisung erlaubt. Beim ersten Kontakt zu einem neuen Host fragt die App einmalig nach.',
|
||||
example: 'r = GET("https://api.example.com/kurse")\nSETVALUE(Kurse::B1; JSONGET(r; "eur.chf"))',
|
||||
snippet: 'r = GET("https://")',
|
||||
keywords: 'http abrufen laden api web url fetch request',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'POST',
|
||||
category: 'Netzwerk & JSON',
|
||||
syntax: 'r = POST(URL; Body)',
|
||||
description:
|
||||
'Sendet den Body-Text als JSON (Content-Type: application/json) und speichert die Antwort in r. Den Body setzt du selbst als Text zusammen, z. B. mit &; Anführungszeichen im Text schreibst du als \\".',
|
||||
example: 'r = POST("https://api.example.com/log"; "{\\"summe\\": " & Umsatz::B8 & "}")',
|
||||
snippet: 'r = POST("https://"; "{}")',
|
||||
keywords: 'http senden api web url request',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'JSONGET',
|
||||
category: 'Netzwerk & JSON',
|
||||
syntax: 'JSONGET(Wert; "pfad")',
|
||||
description:
|
||||
'Liest ein Feld aus einem JSON-Ergebnis. Pfad punktgetrennt, Zahlen indizieren Arrays ("daten.0.name"). Ein fehlender Pfad ergibt null, keinen Fehler.',
|
||||
example: 'r = GET("https://api.example.com/produkte")\nSETVALUE(Kurse::A1; JSONGET(r; "daten.0.name"))',
|
||||
snippet: 'JSONGET(r; "pfad")',
|
||||
keywords: 'json feld lesen pfad objekt',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'FOR über JSON-Array',
|
||||
category: 'Netzwerk & JSON',
|
||||
syntax: 'FOR item IN r DO … END',
|
||||
description: 'Durchläuft die Elemente eines JSON-Arrays (z. B. aus GET oder JSONGET).',
|
||||
example:
|
||||
'r = GET("https://api.example.com/produkte")\nFOR p IN JSONGET(r; "items") DO\n INSERTROW("Produkte"; 1)\n SETVALUE(Produkte::A2; JSONGET(p; "name"))\nEND',
|
||||
snippet: 'FOR item IN JSONGET(r; "pfad") DO\n \nEND',
|
||||
keywords: 'schleife array liste json iteration',
|
||||
},
|
||||
];
|
||||
|
||||
const OPERATORS_MACRO: RefEntry[] = [
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'Rechnen',
|
||||
category: 'Operatoren',
|
||||
syntax: '+ - * / ^',
|
||||
description: 'Grundrechenarten und Potenz. ^ bindet am stärksten, dann * und /, dann + und -.',
|
||||
example: 'SETVALUE(Umsatz::H3; (Umsatz::B2 + Umsatz::C2) / 2)',
|
||||
snippet: ' + ',
|
||||
keywords: 'plus minus mal geteilt potenz rechnen',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'Text verketten',
|
||||
category: 'Operatoren',
|
||||
syntax: 'Text & Text',
|
||||
description: 'Hängt Texte und Zahlen aneinander.',
|
||||
example: 'SETVALUE(Umsatz::A20; "Stand: " & TODAY())',
|
||||
snippet: ' & ',
|
||||
keywords: 'verketten zusammenfügen text string',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'Vergleichen',
|
||||
category: 'Operatoren',
|
||||
syntax: '= <> < <= > >=',
|
||||
description: 'Vergleiche für IF-Bedingungen. Texte werden ohne Groß-/Kleinschreibung verglichen.',
|
||||
example: 'IF Umsatz::B2 >= 1000 THEN … END',
|
||||
snippet: ' >= ',
|
||||
keywords: 'gleich ungleich größer kleiner vergleich',
|
||||
},
|
||||
{
|
||||
contexts: ['macro'],
|
||||
name: 'Bezüge',
|
||||
category: 'Operatoren',
|
||||
syntax: 'Tabelle::A1 Blatt!Tabelle::A1:B5',
|
||||
description:
|
||||
'In Makros muss jeder Zell- und Bereichsbezug die Tabelle nennen. Namen mit Leerzeichen in einfache Anführungszeichen setzen: \'Umsatz 2026\'::A1.',
|
||||
example: "SETVALUE('Blatt 2'!Kosten::B2; 0)",
|
||||
snippet: 'Tabelle::A1',
|
||||
keywords: 'zelle bereich referenz adresse tabelle blatt',
|
||||
},
|
||||
];
|
||||
|
||||
// Function-body counterparts: same grammar, but no write actions -- examples
|
||||
// compute and RETURN a value instead of writing it. Parameter names (bereich,
|
||||
// umsatz, ...) stand for whatever the function declares.
|
||||
const CONTROL_FUNCTION: RefEntry[] = [
|
||||
{
|
||||
contexts: ['block'],
|
||||
name: 'Zuweisung',
|
||||
category: 'Ablauf',
|
||||
syntax: 'name = Ausdruck',
|
||||
description:
|
||||
'Speichert einen Zwischenwert unter einem Namen. Der Name bleibt für den restlichen Code sichtbar, auch nach dem Ende eines FOR/IF.',
|
||||
example: 'marge = (umsatz - kosten) / umsatz\nRETURN marge * 100',
|
||||
snippet: 'name = Ausdruck',
|
||||
keywords: 'variable zuweisen speichern zwischenwert',
|
||||
},
|
||||
{
|
||||
contexts: ['block'],
|
||||
name: 'FOR … IN … DO … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'FOR x IN bereich DO … END',
|
||||
description:
|
||||
'Wiederholt die Anweisungen für jedes Element eines Bereichs. Die Quelle ist ein Parameter, eine Zelle oder ein Bereich, kein berechneter Ausdruck.',
|
||||
example: 'ergebnis = 1\nFOR x IN bereich DO\n ergebnis = ergebnis * x\nEND\nRETURN ergebnis',
|
||||
snippet: 'FOR x IN bereich DO\n \nEND',
|
||||
keywords: 'schleife loop wiederholen jedes element iteration',
|
||||
},
|
||||
{
|
||||
contexts: ['block'],
|
||||
name: 'IF … THEN … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'IF Bedingung THEN … END',
|
||||
description: 'Führt die Anweisungen nur aus, wenn die Bedingung wahr ist. Beliebig verschachtelbar, auch in einer Schleife.',
|
||||
example: 'IF umsatz = 0 THEN RETURN 0 END\nRETURN (umsatz - kosten) / umsatz',
|
||||
snippet: 'IF Bedingung THEN\n \nEND',
|
||||
keywords: 'wenn bedingung verzweigung falls',
|
||||
},
|
||||
{
|
||||
contexts: ['block'],
|
||||
name: 'IF … THEN … ELSE … END',
|
||||
category: 'Ablauf',
|
||||
syntax: 'IF Bedingung THEN … ELSE … END',
|
||||
description: 'Wie IF, mit einem zweiten Zweig, der läuft, wenn die Bedingung falsch ist.',
|
||||
example: 'IF umsatz > kosten THEN\n RETURN "Gewinn"\nELSE\n RETURN "Verlust"\nEND',
|
||||
snippet: 'IF Bedingung THEN\n \nELSE\n \nEND',
|
||||
keywords: 'wenn sonst bedingung verzweigung',
|
||||
},
|
||||
{
|
||||
contexts: ['block'],
|
||||
name: 'RETURN',
|
||||
category: 'Ablauf',
|
||||
syntax: 'RETURN Ausdruck',
|
||||
description:
|
||||
'Liefert den Funktionswert und beendet die Ausführung sofort, auch mitten in einer Schleife oder einem IF-Zweig.',
|
||||
example: 'FOR x IN bereich DO\n IF x < 0 THEN RETURN "negativ" END\nEND\nRETURN "ok"',
|
||||
snippet: 'RETURN ',
|
||||
keywords: 'ergebnis zurückgeben beenden abbrechen',
|
||||
},
|
||||
];
|
||||
|
||||
const OPERATORS_FUNCTION: RefEntry[] = [
|
||||
{
|
||||
contexts: ['block', 'expression'],
|
||||
name: 'Rechnen',
|
||||
category: 'Operatoren',
|
||||
syntax: '+ - * / ^',
|
||||
description: 'Grundrechenarten und Potenz. ^ bindet am stärksten, dann * und /, dann + und -.',
|
||||
example: '(umsatz - kosten) / umsatz * 100',
|
||||
snippet: ' + ',
|
||||
keywords: 'plus minus mal geteilt potenz rechnen',
|
||||
},
|
||||
{
|
||||
contexts: ['block', 'expression'],
|
||||
name: 'Text verketten',
|
||||
category: 'Operatoren',
|
||||
syntax: 'Text & Text',
|
||||
description: 'Hängt Texte und Zahlen aneinander.',
|
||||
example: 'CONCAT("Marge: "; (umsatz - kosten) / umsatz * 100) & " %"',
|
||||
snippet: ' & ',
|
||||
keywords: 'verketten zusammenfügen text string',
|
||||
},
|
||||
{
|
||||
contexts: ['block', 'expression'],
|
||||
name: 'Vergleichen',
|
||||
category: 'Operatoren',
|
||||
syntax: '= <> < <= > >=',
|
||||
description: 'Vergleiche für IF. Texte werden ohne Groß-/Kleinschreibung verglichen.',
|
||||
example: 'IF(umsatz >= kosten; "Gewinn"; "Verlust")',
|
||||
snippet: ' >= ',
|
||||
keywords: 'gleich ungleich größer kleiner vergleich',
|
||||
},
|
||||
{
|
||||
contexts: ['block', 'expression'],
|
||||
name: 'Bereich als Parameter',
|
||||
category: 'Operatoren',
|
||||
syntax: 'SUM(bereich)',
|
||||
description:
|
||||
'Ein Parameter kann ein Bereich sein, wenn er direkt an eine Funktion wie SUM, AVG, MIN oder MAX geht. Aufruf dann z. B. =SPANNE(Umsatz!B2:E6).',
|
||||
example: 'MAX(bereich) - MIN(bereich)',
|
||||
snippet: 'SUM(bereich)',
|
||||
keywords: 'bereich parameter range argument',
|
||||
},
|
||||
];
|
||||
|
||||
const FUNCTION_DESCRIPTIONS: Record<string, string> = {
|
||||
SUM: 'Summe der Werte.',
|
||||
AVG: 'Mittelwert der Werte.',
|
||||
AVERAGE: 'Mittelwert der Werte (wie AVG).',
|
||||
MIN: 'Kleinster Wert.',
|
||||
MAX: 'Größter Wert.',
|
||||
COUNT: 'Anzahl der Zahlen.',
|
||||
COUNT2: 'Anzahl der nicht leeren Zellen.',
|
||||
CONCAT: 'Verkettet Texte.',
|
||||
CONCATENATE: 'Verkettet Texte (wie CONCAT).',
|
||||
IF: 'Wählt je nach Bedingung einen von zwei Werten.',
|
||||
AND: 'Wahr, wenn alle Bedingungen wahr sind.',
|
||||
OR: 'Wahr, wenn mindestens eine Bedingung wahr ist.',
|
||||
NOT: 'Kehrt eine Bedingung um.',
|
||||
DATE: 'Datum aus Jahr, Monat und Tag.',
|
||||
TIME: 'Uhrzeit aus Stunden, Minuten und Sekunden.',
|
||||
TODAY: 'Heutiges Datum.',
|
||||
NOW: 'Aktuelles Datum mit Uhrzeit.',
|
||||
XLOOKUP: 'Sucht einen Wert und gibt den passenden Eintrag aus einem zweiten Bereich zurück.',
|
||||
YEAR: 'Jahr eines Datums.',
|
||||
MONTH: 'Monat eines Datums (1–12).',
|
||||
DAY: 'Tag des Monats (1–31).',
|
||||
HOUR: 'Stunde einer Uhrzeit (0–23).',
|
||||
MINUTE: 'Minute einer Uhrzeit (0–59).',
|
||||
SECOND: 'Sekunde einer Uhrzeit (0–59).',
|
||||
WEEKDAY: 'Wochentag nach ISO: Montag = 1 … Sonntag = 7. Typ 1: Sonntag = 1, Typ 3: Montag = 0.',
|
||||
ISOWEEKNUM: 'Kalenderwoche nach ISO 8601 (Woche 1 enthält den ersten Donnerstag des Jahres).',
|
||||
WEEKNUM: 'Kalenderwoche, standardmäßig nach ISO. Typ 1/2: US-Zählung mit Wochenbeginn Sonntag/Montag.',
|
||||
EDATE: 'Gleicher Tag einige Monate später (negativ: früher), am Monatsende gekürzt.',
|
||||
EOMONTH: 'Letzter Tag des Monats, einige Monate vor oder nach dem Datum.',
|
||||
DAYS: 'Anzahl Kalendertage zwischen zwei Daten.',
|
||||
DATEDIF: 'Abstand zweier Daten in "Y" Jahren, "M" Monaten, "D" Tagen oder als Rest "YM", "MD", "YD".',
|
||||
NETWORKDAYS: 'Arbeitstage (Mo–Fr) zwischen zwei Daten, beide eingeschlossen, ohne Feiertage.',
|
||||
WORKDAY: 'Datum nach einer Anzahl Arbeitstage (Mo–Fr), Feiertage werden übersprungen.',
|
||||
DATEVALUE: 'Datum aus einem Text wie "2026-09-02" oder "02.09.2026".',
|
||||
TIMEVALUE: 'Uhrzeit aus einem Text wie "14:30".',
|
||||
};
|
||||
|
||||
/** German search terms for built-ins whose description doesn't already say it. */
|
||||
const FUNCTION_KEYWORDS: Record<string, string> = {
|
||||
SUM: 'summe addieren',
|
||||
AVG: 'durchschnitt mittelwert',
|
||||
AVERAGE: 'durchschnitt mittelwert',
|
||||
COUNT: 'anzahl zählen',
|
||||
COUNT2: 'anzahl zählen',
|
||||
IF: 'wenn falls bedingung',
|
||||
AND: 'und',
|
||||
OR: 'oder',
|
||||
NOT: 'nicht',
|
||||
TODAY: 'heute datum',
|
||||
NOW: 'jetzt zeit',
|
||||
XLOOKUP: 'suchen sverweis verweis nachschlagen',
|
||||
YEAR: 'jahr datum',
|
||||
MONTH: 'monat datum',
|
||||
DAY: 'tag datum',
|
||||
HOUR: 'stunde zeit uhrzeit',
|
||||
MINUTE: 'minute zeit uhrzeit',
|
||||
SECOND: 'sekunde zeit uhrzeit',
|
||||
WEEKDAY: 'wochentag tag woche',
|
||||
ISOWEEKNUM: 'kalenderwoche kw woche',
|
||||
WEEKNUM: 'kalenderwoche kw woche',
|
||||
EDATE: 'monat addieren datum',
|
||||
EOMONTH: 'monatsende ultimo',
|
||||
DAYS: 'tage differenz abstand',
|
||||
DATEDIF: 'differenz abstand alter jahre monate tage',
|
||||
NETWORKDAYS: 'arbeitstage werktage feiertage',
|
||||
WORKDAY: 'arbeitstag werktag feiertage frist',
|
||||
DATEVALUE: 'datum text umwandeln',
|
||||
TIMEVALUE: 'zeit uhrzeit text umwandeln',
|
||||
};
|
||||
|
||||
const FUNCTIONS: RefEntry[] = BUILTIN_SIGNATURES.map((s) => ({
|
||||
contexts: ['macro', 'block', 'expression'],
|
||||
name: s.name,
|
||||
category: 'Formelfunktionen' as const,
|
||||
syntax: s.hint,
|
||||
description: FUNCTION_DESCRIPTIONS[s.name] ?? 'Formelfunktion.',
|
||||
keywords: FUNCTION_KEYWORDS[s.name],
|
||||
snippet: `${s.name}()`,
|
||||
}));
|
||||
|
||||
export const CODE_REFERENCE: RefEntry[] = [
|
||||
...ACTIONS,
|
||||
...CONTROL_MACRO,
|
||||
...CONTROL_FUNCTION,
|
||||
...NETWORK,
|
||||
...OPERATORS_MACRO,
|
||||
...OPERATORS_FUNCTION,
|
||||
...FUNCTIONS,
|
||||
];
|
||||
|
||||
export const referenceFor = (ctx: RefContext): RefEntry[] =>
|
||||
CODE_REFERENCE.filter((e) => e.contexts.includes(ctx));
|
||||
|
||||
/** Case-insensitive match over name, syntax, description and keywords. */
|
||||
export function matchesQuery(
|
||||
e: Pick<RefEntry, 'name' | 'syntax' | 'description' | 'keywords'>,
|
||||
query: string,
|
||||
): boolean {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (!words.length) return true;
|
||||
const hay = `${e.name} ${e.syntax} ${e.description} ${e.keywords ?? ''}`.toLowerCase();
|
||||
return words.every((w) => hay.includes(w));
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Document templates: `createBlankDocument()` (Datei › Neu, and what a first
|
||||
* start opens) and `createSeedDocument()` (Datei › Muster).
|
||||
*
|
||||
* The pre-filled document ("roter Faden" of the study), in the v2 canvas model:
|
||||
* one sheet ("Analyse") carrying three table objects and a chart object, plus a
|
||||
* second empty sheet to try things out.
|
||||
*
|
||||
* Note on the brief: the acceptance list mentions "Übersicht!B2 = 6070" as the
|
||||
* yearly Nord revenue, but the given Nord row is 1200 + 1450 + 1600 + 1720 =
|
||||
* 5970. The explicit table wins -- the exact number is deterministic and the
|
||||
* propagation behaviour it demonstrates is unchanged.
|
||||
*/
|
||||
|
||||
import { parseA1 } from '../engine';
|
||||
import type { ChartObject, DocumentState, TableObject } from '../store/types';
|
||||
import { defaultTableView } from '../store/types';
|
||||
|
||||
let uid = 0;
|
||||
const nextId = (p: string) => `${p}-seed-${(uid++).toString(36)}`;
|
||||
|
||||
function makeTable(
|
||||
name: string,
|
||||
x: number,
|
||||
y: number,
|
||||
rows: number,
|
||||
cols: number,
|
||||
entries: Array<[string, string | number]>,
|
||||
colWidths: Record<number, number> = {},
|
||||
viewOverride: Partial<TableObject['view']> = {},
|
||||
): TableObject {
|
||||
const cells: Record<string, string> = {};
|
||||
for (const [a1, value] of entries) {
|
||||
const { col, row } = parseA1(a1);
|
||||
cells[`${col},${row}`] = String(value);
|
||||
}
|
||||
return {
|
||||
kind: 'table',
|
||||
id: nextId('tbl'),
|
||||
name,
|
||||
x,
|
||||
y,
|
||||
rows,
|
||||
cols,
|
||||
colWidths,
|
||||
cells,
|
||||
view: { ...defaultTableView(), ...viewOverride },
|
||||
};
|
||||
}
|
||||
|
||||
export function createSeedDocument(): DocumentState {
|
||||
const umsatz = makeTable('Umsatz', 40, 40, 20, 8, [
|
||||
['A1', 'Region'], ['B1', 'Q1'], ['C1', 'Q2'], ['D1', 'Q3'], ['E1', 'Q4'],
|
||||
['A2', 'Nord'], ['B2', 1200], ['C2', 1450], ['D2', 1600], ['E2', 1720],
|
||||
['A3', 'Süd'], ['B3', 980], ['C3', 1100], ['D3', 1250], ['E3', 1310],
|
||||
['A4', 'Ost'], ['B4', 1550], ['C4', 1480], ['D4', 1620], ['E4', 1700],
|
||||
['A5', 'West'], ['B5', 1340], ['C5', 1390], ['D5', 1500], ['E5', 1580],
|
||||
['A6', 'Zentral'], ['B6', 1105], ['C6', 1205], ['D6', 1298], ['E6', 1402],
|
||||
['A8', 'Summe'],
|
||||
['B8', '=SUM(B2:B6)'], ['C8', '=SUM(C2:C6)'], ['D8', '=SUM(D2:D6)'], ['E8', '=SUM(E2:E6)'],
|
||||
['A9', 'Mittelwert'],
|
||||
['B9', '=AVG(B2:B6)'], ['C9', '=AVG(C2:C6)'], ['D9', '=AVG(D2:D6)'], ['E9', '=AVG(E2:E6)'],
|
||||
['A10', 'Minimum'],
|
||||
['B10', '=MIN(B2:B6)'], ['C10', '=MIN(C2:C6)'], ['D10', '=MIN(D2:D6)'], ['E10', '=MIN(E2:E6)'],
|
||||
['A11', 'Maximum'],
|
||||
['B11', '=MAX(B2:B6)'], ['C11', '=MAX(C2:C6)'], ['D11', '=MAX(D2:D6)'], ['E11', '=MAX(E2:E6)'],
|
||||
['A12', 'Anzahl'],
|
||||
['B12', '=COUNT(B2:B6)'], ['C12', '=COUNT(C2:C6)'], ['D12', '=COUNT(D2:D6)'], ['E12', '=COUNT(E2:E6)'],
|
||||
['G2', 'Jahr Nord'], ['H2', '=SUM(B2:E2)'],
|
||||
['G3', 'Schnitt/Q'], ['H3', '=SUM(B2:E2)/4'],
|
||||
['G4', 'Delta Q4-Q1'], ['H4', '=E2-B2'],
|
||||
], { 0: 92, 6: 96 }, { mode: 'fixed', width: 545, height: 360, frozenRows: 1, frozenCols: 1 });
|
||||
|
||||
const kosten = makeTable('Kosten', 40, 700, 12, 8, [
|
||||
['A1', 'Region'], ['B1', 'Q1'], ['C1', 'Q2'], ['D1', 'Q3'], ['E1', 'Q4'],
|
||||
['A2', 'Nord'], ['B2', 800], ['C2', 820], ['D2', 900], ['E2', 880],
|
||||
['A3', 'Süd'], ['B3', 700], ['C3', 690], ['D3', 710], ['E3', 720],
|
||||
['A4', 'Ost'], ['B4', 900], ['C4', 910], ['D4', 950], ['E4', 980],
|
||||
['A5', 'West'], ['B5', 760], ['C5', 770], ['D5', 780], ['E5', 790],
|
||||
['A6', 'Zentral'], ['B6', 640], ['C6', 650], ['D6', 660], ['E6', 670],
|
||||
], { 0: 92 });
|
||||
|
||||
const regions = ['Nord', 'Süd', 'Ost', 'West', 'Zentral'];
|
||||
const overview: Array<[string, string | number]> = [
|
||||
['A1', 'Region'], ['B1', 'Umsatz Jahr'], ['C1', 'Kosten Jahr'],
|
||||
['D1', 'Deckungsbeitrag'], ['E1', 'Marge %'],
|
||||
];
|
||||
regions.forEach((region, i) => {
|
||||
const r = i + 2;
|
||||
overview.push(
|
||||
[`A${r}`, region],
|
||||
[`B${r}`, `=SUM(Umsatz!B${r}:E${r})`],
|
||||
[`C${r}`, `=SUM(Kosten!B${r}:E${r})`],
|
||||
[`D${r}`, `=B${r}-C${r}`],
|
||||
[`E${r}`, `=D${r}/B${r}*100`],
|
||||
);
|
||||
});
|
||||
overview.push(
|
||||
['A8', 'Gesamt'],
|
||||
['B8', '=SUM(B2:B6)'], ['C8', '=SUM(C2:C6)'],
|
||||
['D8', '=SUM(D2:D6)'], ['E8', '=AVG(E2:E6)'],
|
||||
);
|
||||
const uebersicht = makeTable('Übersicht', 1000, 40, 12, 6, overview, {
|
||||
0: 84, 3: 118,
|
||||
});
|
||||
|
||||
const chart: ChartObject = {
|
||||
kind: 'chart',
|
||||
id: nextId('cht'),
|
||||
name: 'Umsatz je Region',
|
||||
x: 1000,
|
||||
y: 460,
|
||||
width: 560,
|
||||
height: 340,
|
||||
view: 'bar',
|
||||
sourceTableId: umsatz.id,
|
||||
};
|
||||
|
||||
const analyse = {
|
||||
id: nextId('sheet'),
|
||||
name: 'Analyse',
|
||||
objects: [umsatz, kosten, uebersicht, chart],
|
||||
};
|
||||
// second sheet demos the per-sheet backdrop colour + hidden grid
|
||||
const blatt2 = {
|
||||
id: nextId('sheet'),
|
||||
name: 'Blatt 2',
|
||||
objects: [],
|
||||
background: '#f4f1ff',
|
||||
showGrid: false,
|
||||
};
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
sheets: [analyse, blatt2],
|
||||
activeSheetId: analyse.id,
|
||||
activeObjectId: umsatz.id,
|
||||
selection: { tableId: umsatz.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
|
||||
locale: 'de-DE',
|
||||
csvDelimiter: ',',
|
||||
customFunctions: [],
|
||||
namedRanges: [
|
||||
// demos "🏷 Namen": =Umsatz_Q1 anywhere resolves to Umsatz!B2:B6, even
|
||||
// from a different sheet, without a table qualifier.
|
||||
{ id: nextId('nr'), name: 'Umsatz_Q1', tableId: umsatz.id, start: { col: 1, row: 1 }, end: { col: 1, row: 5 } },
|
||||
],
|
||||
macros: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** An empty document: one sheet with one empty table, ready to type into. */
|
||||
export function createBlankDocument(): DocumentState {
|
||||
const table = makeTable('Tabelle 1', 40, 40, 12, 6, []);
|
||||
const sheet = { id: nextId('sheet'), name: 'Blatt 1', objects: [table] };
|
||||
return {
|
||||
version: 2,
|
||||
sheets: [sheet],
|
||||
activeSheetId: sheet.id,
|
||||
activeObjectId: table.id,
|
||||
selection: { tableId: table.id, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } },
|
||||
locale: 'de-DE',
|
||||
csvDelimiter: ',',
|
||||
customFunctions: [],
|
||||
namedRanges: [],
|
||||
macros: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
# Formel-Engine
|
||||
|
||||
Selbst geschriebene, framework-agnostische Formel-Engine. Kein HyperFormula, kein
|
||||
`formula-parser`, keine Spreadsheet-Library. Reines ES-Modul, in Vitest getestet.
|
||||
|
||||
Die adressierbare Einheit ist eine **Tabelle** – ein Tabellen-Objekt auf einer
|
||||
Numbers-artigen Blattfläche. Tabellennamen sind nur **pro Blatt** eindeutig
|
||||
(zwei Blätter dürfen je eine Tabelle "Umsatz" haben) – `Umsatz!B2` (bzw.
|
||||
`Umsatz::B2`) löst dann zur Tabelle auf dem **gleichen Blatt wie die Formel**
|
||||
auf, sofern eindeutig; die explizite Form `Blatt!Tabelle::B2` trifft immer
|
||||
genau die genannte Tabelle. Siehe "Blatt-Qualifier" weiter unten.
|
||||
|
||||
## Module
|
||||
|
||||
| Datei | Aufgabe |
|
||||
|----------------|---------|
|
||||
| `lexer.ts` | Tokenizer. Flacher Token-Strom, keine strukturellen Entscheidungen. Kennt `!` und `::`. |
|
||||
| `parser.ts` | Recursive-Descent → AST. Grammatik als EBNF-Kommentarblock im Kopf der Datei. |
|
||||
| `evaluator.ts` | AST + `EngineHost` → `CellValue`. Numerisches Auswertungsmodell, Fehler-Propagierung. Enthält auch den kleinen Interpreter für `kind: 'block'`-Funktionsaufrufe (`BlockCallNode`) – siehe „Schleifen" unten. |
|
||||
| `graph.ts` | Dokumentweiter Abhängigkeitsgraph: `affected()`, `topoOrder()`, Zirkel-Erkennung (iterative DFS, white/grey/black). |
|
||||
| `refs.ts` | A1 ↔ `(col,row)` inkl. `$`-Sperrachsen, Spaltenbuchstaben, Bereichs-Expansion, `F4`-Zyklus (`cycleReferenceLocks`), Funktionsnamen-Erkennung fürs Autovervollständigen (`currentIdentifierAtCaret`). |
|
||||
| `functions.ts` | `SUM/AVG/MIN/MAX/COUNT` (+ Alias `AVERAGE`), `DATE/TIME/TODAY/NOW` als Tabelle `{ NAME → fn(values) }`. `IF` und `XLOOKUP` sind hier nur als Platzhalter registriert (für `hasFunction()`/Namensreservierung); die echte Auswertung sitzt in `evaluator.ts`, s. u. Eingebaute Namen sind nie überschreibbar. |
|
||||
| `dates.ts` | Datum/Zeit als Zahl ("Serial", Tage seit 1899-12-30): `dateToSerial`/`timeSerial` (Bausteine für `DATE`/`TIME`), `todaySerial`/`nowSerial`, `serialToUtcDate` (für die Anzeige), `parseDateTimeLiteral` (Eingabe-Erkennung, s. u. „Datum & Zeit"). |
|
||||
| `userFunctions.ts` | Benutzerdefinierte Funktionen (dokumentweite Bibliothek, angelegt über die UI — siehe Haupt-`README.md`). `expandUserFunctions()` setzt einen `kind: 'expression'`-Aufruf beim Parsen der aufrufenden Zelle als Kopie des rekursiv expandierten, parametersubstituierten Funktionskörpers in deren AST ein; einen `kind: 'block'`-Aufruf (FOR/IF/RETURN-Code) verpackt sie stattdessen in einen `BlockCallNode` mit bereits aufgelösten verschachtelten Aufrufen, siehe „Schleifen" unten. |
|
||||
| `workbook.ts` | Bindeglied: flache Liste von **Tabellen** + rohe Eingaben + ASTs + Werte + Graph. Inkrementelle Neuberechnung. Implementiert `EngineHost`. Ruft `expandUserFunctions()` direkt nach `parseFormula()`. `literalValue()` erkennt Datums-/Zeit-Eingaben (`dates.ts`) vor dem generischen Zahl-Test. |
|
||||
| `types.ts` | Gemeinsame Typen, `FormulaError`, `keyOf()`. |
|
||||
|
||||
Die Engine kennt **kein** Vue. Der Vue-Store (`src/store/`) legt eine `Workbook`-
|
||||
Instanz in ein `reactive()` und spiegelt berechnete Werte über einen
|
||||
`recalcTick`-Ref – dadurch verhält sich jede Formelzelle im UI wie ein `computed`.
|
||||
|
||||
## Grammatik (EBNF)
|
||||
|
||||
```ebnf
|
||||
formula = "=" expression ;
|
||||
|
||||
expression = comparison ;
|
||||
comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ; (* höchstens
|
||||
ein Vergleich, nicht
|
||||
verkettbar; Ergebnis
|
||||
1/0 (siehe Auswertungs-
|
||||
modell unten) *)
|
||||
concat = additive { "&" additive } ; (* links-assoziativ; Text-
|
||||
Verkettung, zwischen
|
||||
Vergleich und "+ -" *)
|
||||
additive = term { ("+" | "-") term } ; (* links-assoziativ; die
|
||||
alte "expression"-Regel,
|
||||
bevor Vergleiche
|
||||
existierten *)
|
||||
term = unary { ("*" | "/") unary } ; (* links-assoziativ *)
|
||||
unary = ("+" | "-") unary
|
||||
| power ;
|
||||
power = primary [ "^" unary ] ; (* rechts-assoziativ; bindet
|
||||
stärker als unäres Minus:
|
||||
-2^2 = -(2^2) = -4 *)
|
||||
primary = NUMBER
|
||||
| STRING
|
||||
| ERRORLIT
|
||||
| "(" expression ")"
|
||||
| funcall
|
||||
| reference
|
||||
| namedref ;
|
||||
|
||||
funcall = IDENT "(" [ expression { ";" expression } ] ")" ;
|
||||
|
||||
namedref = IDENT ; (* unqualifizierter, nicht zellförmiger
|
||||
Bezeichner, kein Parameter -- ein
|
||||
benannter-Bereich-Kandidat (s. u.),
|
||||
nur außerhalb eines Funktionskörpers *)
|
||||
|
||||
reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
|
||||
qualifier = (IDENT | QUOTED) ("!" | "::") ; (* Tabellenname; "!" und der
|
||||
Numbers-Stil "::" sind
|
||||
austauschbar *)
|
||||
sheetQualifier = (IDENT | QUOTED) "!" ; (* Blattname -- nur erkannt,
|
||||
wenn direkt gefolgt von
|
||||
vollem "Tabelle::" (die
|
||||
3-teilige Form); ein
|
||||
einzelnes "X!Y" bleibt
|
||||
die alte, rückwärts-
|
||||
kompatible 2-teilige
|
||||
Form (X = Tabelle) *)
|
||||
|
||||
NUMBER = /[0-9]+ ("." [0-9]+)? ([eE] [+-]? [0-9]+)?/ | /"." [0-9]+/ ;
|
||||
STRING = /" ... "/ (Backslash-Escapes) ;
|
||||
IDENT = /[A-Za-z_][A-Za-z0-9_]*/ ;
|
||||
QUOTED = /' ( [^'] | '' )* '/ (* Tabellenname mit Leer-/Sonderzeichen *)
|
||||
CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||
ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
|
||||
"#VALUE!" | "#N/A" ; (* longest-match in the lexer -- see
|
||||
lexer.ts's ERROR_CODES_BY_LENGTH *)
|
||||
```
|
||||
|
||||
Präzedenz, schwach → stark: Vergleich (`= <> > < >= <=`) < `&` < `+ -` < `* /` < unäres `+ -` < `^`.
|
||||
|
||||
`$` sperrt eine Referenz-Achse: `$A1` (Spalte fix), `A$1` (Zeile fix), `$A$1`
|
||||
(beide fix) vs. `A1` (relativ). Für die **Auswertung** ist das irrelevant – sie
|
||||
ist rein positionsbasiert, `$` wird geparst und sonst ignoriert. Es wirkt sich
|
||||
ausschließlich beim **Kopieren/Ausschneiden** aus (`shiftFormula` in
|
||||
`src/store/document.ts`, außerhalb der Engine): eine gesperrte Achse bleibt
|
||||
beim *Kopieren* exakt wie eingegeben stehen, folgt aber beim *Ausschneiden*
|
||||
trotzdem der verschobenen Zelle (dort verschiebt sich die referenzierte Zelle
|
||||
selbst – `$` ändert nur, ob eine *Kopie* mitzieht, nicht wohin ein *Verschieben*
|
||||
zeigt). `refs.ts#cycleReferenceLocks` implementiert den `F4`-Zyklus
|
||||
(relativ → `$A$1` → `A$1` → `$A1` → relativ) für Formelleiste und Zell-Editor
|
||||
— mit Cursor nur den einen Bezug dort, mit echter Textauswahl **jeden**
|
||||
darin gefundenen Bezug auf einmal, jeden ausgehend vom eigenen Zustand.
|
||||
Tabellennamen werden **case-insensitiv** und whitespace-tolerant aufgelöst:
|
||||
`Kosten!A2`, `'Kosten'!A2`, `kosten!a2`, `Kosten::A2` sind identisch.
|
||||
|
||||
`refs.ts#currentIdentifierAtCaret(formula, caret)` treibt die Funktions-
|
||||
Autovervollständigung (Formelleiste und Zell-Editor, `src/composables/
|
||||
useFormulaAutocomplete.ts`): strukturell dasselbe Muster wie
|
||||
`cycleReferenceLocks` (`=` abstreifen, tokenizen, am Cursor nachsehen), aber
|
||||
nur der Text **bis** zum Cursor wird überhaupt tokenisiert (was danach
|
||||
kommt, spielt nie eine Rolle — wie bei den meisten Editoren), und nur das
|
||||
*letzte* Token zählt. Ein `IDENT`-Token, das bis genau an den Cursor reicht
|
||||
und nicht wie ein Zellbezug aussieht (`looksLikeCell`), ist der gerade
|
||||
getippte Funktionsname; alles andere (kein Token dort, ein Zellbezug, der
|
||||
Cursor sitzt hinter Leerraum nach einem abgeschlossenen Wort, `tokenize()`
|
||||
wirft bei einem kaputten Präfix) liefert `null` — kein Dropdown.
|
||||
|
||||
`parseFormula(src, paramNames?)` akzeptiert einen optionalen zweiten
|
||||
Parameter: eine Liste von Namen, gegen die ein bare `IDENT` (das nicht wie
|
||||
eine Zelle aussieht) zusätzlich geprüft wird — ein Treffer erzeugt statt eines
|
||||
`ParseError` einen `ParamNode`. Nur beim Parsen eines **Funktionskörpers**
|
||||
verwendet (`userFunctions.ts`).
|
||||
|
||||
Ohne das Argument (`paramNames === undefined`, jede gewöhnliche
|
||||
Top-Level-Zellformel) hat sich das Verhalten für einen bare Bezeichner ohne
|
||||
Zellform seit **benannten Bereichen** geändert: er parst jetzt als
|
||||
`NamedRefNode` (Feld `namedRefFallback` am `Parser`, s. u. „Benannte
|
||||
Bereiche") statt als `ParseError`. **Mit** `paramNames` — auch als leeres
|
||||
Array, d. h. innerhalb eines Funktionskörpers — bleibt das alte, strikte
|
||||
Verhalten exakt erhalten: ein bare, nicht-zellförmiger, nicht-Parameter-Name
|
||||
ist weiterhin ein harter `ParseError`. Grund: eine lokale/Schleifen-Variable,
|
||||
die vor ihrer eigenen deklarierenden Anweisung verwendet wird (`x = x + 1`),
|
||||
soll sofort beim Speichern scheitern, statt sich still zu einem
|
||||
gleichnamigen benannten Bereich im Dokument aufzulösen (oder erst zur
|
||||
Laufzeit als `#NAME?` aufzufallen).
|
||||
|
||||
## Blatt-Qualifier ("Blatt!Tabelle::Zelle")
|
||||
|
||||
`parser.ts#Parser.afterFirstQualifierToken` entscheidet per Lookahead, ob ein
|
||||
`!` einen (alten) Tabellen-Qualifier einleitet oder einen **Blatt**-Qualifier:
|
||||
nur wenn direkt danach `IDENT/QUOTED "::"` folgt, ist es ein Blattname und der
|
||||
Parser konsumiert drei Segmente (`Blatt`, `Tabelle`, `Zelle`) statt zwei —
|
||||
sonst bleibt die alte, einstufige Bedeutung (`X!Y` = Tabelle `X`, Zelle `Y`)
|
||||
exakt erhalten. `CellNode`/`RangeNode` bekommen dafür ein optionales
|
||||
`sheetQualifier?: string`-Feld neben dem bestehenden `qualifier`.
|
||||
|
||||
Die Auflösung passiert in `workbook.ts#Workbook.resolveTableName(name,
|
||||
sheetName?)` (Teil von `EngineHost`):
|
||||
|
||||
- `sheetName` gegeben (3-teilige Form) → nur eine Tabelle mit diesem Namen
|
||||
**auf diesem Blatt** trifft; sonst `null` (→ `#REF`).
|
||||
- `sheetName` fehlt und der Tabellenname ist **eindeutig** im Dokument → wie
|
||||
bisher, unverändert.
|
||||
- `sheetName` fehlt und der Name kommt auf **mehreren** Blättern vor →
|
||||
bevorzugt die Tabelle auf demselben Blatt wie `this.homeTableId` (dafür
|
||||
setzen sowohl `evaluateCell` als auch `precedentsOf` `homeTableId`, bevor
|
||||
sie referenzieren — Letzteres, damit auch die **Graph-Kanten** beim Parsen
|
||||
schon dieselbe Auflösung wie später die Auswertung verwenden). Bleibt es
|
||||
danach mehrdeutig: `null` (→ `#REF`).
|
||||
|
||||
`TableModel` (workbook.ts) trägt dafür zusätzlich `sheetName: string`. Die
|
||||
Store-Seite (`document.ts#toTableModels`) befüllt es aus dem Blatt, dem die
|
||||
Tabelle gehört; `tableNamesOnSheet(sheetId)`/`objectNamesOnSheet(sheetId)`
|
||||
ersetzen dort die früher dokumentweiten Eindeutigkeits-Prüfungen beim Anlegen/
|
||||
Umbenennen einer Tabelle. `qualifyRef()` (Point-Mode) fügt die volle
|
||||
3-teilige Form nur dann ein, wenn der angeklickte Tabellenname wirklich
|
||||
mehrdeutig **und** auf einem anderen Blatt als der Editor liegt — sonst reicht
|
||||
weiterhin die einfache Form.
|
||||
|
||||
## Benutzerdefinierte Funktionen
|
||||
|
||||
Kein Laufzeit-Closure, keine Änderung an `evaluator.ts`/`graph.ts`: ein Aufruf
|
||||
einer benutzerdefinierten Funktion wird **beim Parsen der aufrufenden Zelle
|
||||
in deren AST eingesetzt** (Inlining). `userFunctions.ts#expandUserFunctions`
|
||||
läuft direkt nach `parseFormula()` in `workbook.ts#parseCell` und ersetzt
|
||||
jeden `FuncNode`, dessen Name in der Funktions-Bibliothek steht (eingebaute
|
||||
Namen haben Vorrang und werden nie ersetzt), durch eine Kopie des – selbst
|
||||
wieder rekursiv expandierten – Funktionskörpers, mit jedem `ParamNode` durch
|
||||
den zugehörigen Argument-Teilbaum des Aufrufers ersetzt. Danach sieht die
|
||||
gespeicherte AST der Zelle so aus, als wäre die Formel von Hand ausgeschrieben
|
||||
worden – `collectRefs`/`precedentsOf`/`evaluate` laufen unverändert, auch für
|
||||
Zellen, die *nur* im Funktionskörper referenziert werden, nicht im Aufruf.
|
||||
|
||||
Ein Arity-Mismatch oder eine Expansionstiefe über 32 (Sicherheitsnetz gegen
|
||||
einen Definitionszyklus, der eigentliche Schutz ist die Zyklus-Prüfung beim
|
||||
**Speichern** einer Funktion im Store) wirft in `expandUserFunctions` einen
|
||||
`Error`, den `parseCell` genau wie einen `ParseError` zu `#ERR` macht. Ein
|
||||
unbekannter Funktionsname – eingebaut oder benutzerdefiniert – bleibt exakt
|
||||
derselbe Pfad wie zuvor: der `FuncNode` bleibt unverändert stehen,
|
||||
`evaluate()`s `getFunction()` findet ihn nicht → `#NAME?`.
|
||||
|
||||
## Benannte Bereiche
|
||||
|
||||
Anders als eine benutzerdefinierte Funktion wird ein `NamedRefNode`
|
||||
**nicht** beim Parsen aufgelöst/inlined, sondern **live, bei jeder
|
||||
Auswertung**, gegen `EngineHost.resolveNamedRange(name)` – dieselbe
|
||||
"Name wird nicht in die AST gebacken"-Philosophie wie ein Tabellen-Qualifier
|
||||
(auch der wird nie zur Parse-Zeit zu einer Tabellen-*ID* aufgelöst). Grund:
|
||||
so übersteht ein benannter Bereich eine spätere Umbenennung *seiner*
|
||||
Zieltabelle, ohne dass irgendeine gespeicherte Formel neu geparst werden
|
||||
müsste.
|
||||
|
||||
- `types.ts#NamedRangeDef` (`{id, name, tableId, start, end}`) wird dem
|
||||
`Workbook`-Konstruktor als dritter Parameter mitgegeben (analog zu den
|
||||
`FunctionDef`s); `store/document.ts#toNamedRangeDefs` mappt
|
||||
`DocumentState.namedRanges` dorthin.
|
||||
- `workbook.ts#Workbook.resolveNamedRange(name)` sucht case-insensitiv/
|
||||
getrimmt (dieselbe Konvention wie `resolveTableName`), löst `tableId` über
|
||||
`tableById()` in den **aktuellen** Tabellennamen auf und gibt
|
||||
`{qualifier, sheetQualifier, start, end}` zurück – exakt die Form, die ein
|
||||
`CellNode`/`RangeNode` sonst auch trägt. Zieltabelle nicht mehr vorhanden
|
||||
→ `null` → `#NAME?` (dieselbe Fehlerklasse wie ein unbekannter Name).
|
||||
- `evaluator.ts#resolveNamedRef()` baut aus diesem Ergebnis einen
|
||||
synthetischen `CellNode` (1×1-Bereich) oder `RangeNode` und lässt jeden
|
||||
Aufrufer (`evalScalar`s `case 'namedref'`, `evalArg`,
|
||||
`rangeOrCellToCellNodes` für eine `FOR`-Quelle) in die **bereits
|
||||
vorhandene** Zell-/Bereichs-Logik durchfallen – keine Duplikation.
|
||||
- `parser.ts#collectRefs` sammelt einen `NamedRefNode` unaufgelöst (der
|
||||
Parser kennt das Dokument nicht); `workbook.ts#precedentsOf` löst ihn
|
||||
dann über `resolveNamedRange` in konkrete Zell-Keys für den
|
||||
Abhängigkeitsgraphen auf – unbekannter Name → keine Kante (keine
|
||||
Neuberechnung nötig, `evaluate()` liefert ohnehin `#NAME?`).
|
||||
- **Grammatik-Änderung, die das erst ermöglicht:** ein unqualifizierter,
|
||||
nicht zellförmiger, nicht Parameter-Bezeichner ist jetzt ein `NamedRefNode`
|
||||
statt eines `ParseError` – aber **nur außerhalb eines Funktionskörpers**
|
||||
(`Parser`s `namedRefFallback`-Flag, `true` nur wenn `parseFormula()` ganz
|
||||
ohne `paramNames`-Argument aufgerufen wird). Ein Aufrufer kann trotzdem
|
||||
einen benannten Bereich als Argument an eine Funktion übergeben – der
|
||||
`NamedRefNode` steht dann in `node.args`, wird über
|
||||
`expand()`/`substitute()` (beide bekamen dafür einen `case 'namedref':
|
||||
return node;`-Leaf, wie `cell`/`range`) an die passende Parameterstelle im
|
||||
Funktionskörper substituiert, und trifft dort z. B. als `FOR`-Quelle auf
|
||||
`rangeOrCellToCellNodes`, das ihn ganz normal auflöst.
|
||||
`userFunctions.ts#findInvalidForSource` erlaubt `namedref` deshalb
|
||||
ausdrücklich als gültige `FOR`-Quellenform (neben `param`/`cell`/`range`).
|
||||
- **Kein Zyklus-Check.** Ein benannter Bereich hat keinen eigenen Ausdruck,
|
||||
nur feste Koordinaten – er kann sich nicht selbst referenzieren, anders
|
||||
als eine benutzerdefinierte Funktion.
|
||||
|
||||
## Schleifen ("Mit Schleife"-Funktionen, `kind: 'block'`)
|
||||
|
||||
Eine zweite, eigene Grammatik nur für Funktionskörper mit `kind: 'block'`
|
||||
(neben der oben beschriebenen Ausdrucks-Form, `kind: 'expression'`, die
|
||||
unverändert bleibt) – mit echtem Kontrollfluss:
|
||||
|
||||
```ebnf
|
||||
functionBlock = block ; (* ein Block = die ganze Funktion *)
|
||||
block = { statement } ;
|
||||
statement = assignment | forLoop | ifStmt | returnStmt ;
|
||||
assignment = IDENT "=" expression ;
|
||||
forLoop = "FOR" IDENT "IN" expression "DO" block "END" ;
|
||||
ifStmt = "IF" expression "THEN" block [ "ELSE" block ] "END" ;
|
||||
returnStmt = "RETURN" expression ;
|
||||
```
|
||||
|
||||
`FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/`ELSE`/`RETURN` sind – wie Funktionsnamen –
|
||||
gewöhnliche `IDENT`-Token, case-insensitiv per Wortvergleich erkannt
|
||||
(`Parser.isKeyword`), kein eigener Lexer-Token dafür nötig; `=` als Zuweisung
|
||||
nutzt den `EQUALS`-Token (rein additiv – vorher ein `LexError`, kann also
|
||||
keine vorher gültige Formel beeinflussen; derselbe Token dient in
|
||||
`expression()` als Gleichheits-Vergleich, die beiden Grammatiken kollidieren
|
||||
nicht, weil eine Zuweisung nur an Anweisungs-Anfang steht). `RETURN` ist eine
|
||||
gewöhnliche **Anweisung**, keine feste Blockendung – sie darf überall
|
||||
stehen, auch verschachtelt in `FOR`/`IF`.
|
||||
|
||||
`parseFunctionBlock(src, paramNames)` (`parser.ts`) parst rekursiv (`parse-
|
||||
StatementBlock` ruft sich über `FOR`/`IF`-Körper selbst wieder auf): jeder
|
||||
eingeführte Name (Zuweisungsziel oder Schleifenvariable) wird sofort zu
|
||||
`Parser.paramNames` hinzugefügt – **flacher Namensraum**, kein Scope-Stack,
|
||||
ein Name bleibt für den *Rest* der Funktion sichtbar, auch nach seinem
|
||||
eigenen `FOR`/`IF`. `AssignNode`/`ForNode`/`IfNode`/`ReturnNode`/`BlockNode`
|
||||
sind bewusst **nicht** Teil des `Node`-Unions – nur der eine Knoten, der
|
||||
tatsächlich in einer normalen Zell-AST landet, ist es:
|
||||
|
||||
```ts
|
||||
interface BlockCallNode {
|
||||
kind: 'blockcall';
|
||||
block: BlockNode; // bereits: verschachtelte Aufrufe anderer Funktionen aufgelöst
|
||||
args: Node[]; // die echten Argument-Ausdrücke des Aufrufers
|
||||
paramNames: string[];
|
||||
label: string; // für Fehlermeldungen
|
||||
}
|
||||
```
|
||||
|
||||
**Warum kein Abrollen mehr (wie noch bei einer früheren Version dieses
|
||||
Features):** ein `IF`-Zweig oder ein bedingtes `RETURN` hängt vom
|
||||
tatsächlichen Zellwert ab – das lässt sich nicht mehr rein aus der
|
||||
Bereichsgröße vorausberechnen. `userFunctions.ts#expand()`s `func`-Fall löst
|
||||
für `kind: 'block'` deshalb nur noch **verschachtelte Funktionsaufrufe**
|
||||
innerhalb des Blocks auf (`expandBlock()`, läuft wie `expand()` durch jede
|
||||
Anweisung/jeden verschachtelten `FOR`/`IF`-Körper, mit denselben "von innen
|
||||
nach außen"-Regeln wie bisher) und verpackt das Ergebnis unverändert in einen
|
||||
`BlockCallNode` – die *eigenen* Parameter dieser Funktion bleiben als
|
||||
`ParamNode` im Block stehen, erst zur Auswertungszeit gebunden.
|
||||
|
||||
**Der Interpreter (`evaluator.ts`)** läuft, wenn `evalScalar` auf einen
|
||||
`blockcall`-Knoten trifft:
|
||||
|
||||
- `evaluateBlockCall(node, host)` baut die Startumgebung `env: Map<string,
|
||||
Node>` aus `paramNames`/`args` und ruft `execBlock`/`execStatement` auf.
|
||||
- Eine `assignment`/`return`/`ifstmt`-Bedingung wird über `substitute(expr,
|
||||
env)` (aus `userFunctions.ts`, dafür **exportiert**) gegen `env` aufgelöst
|
||||
und dann ganz normal `evalScalar`-ausgewertet; das Ergebnis wird (als
|
||||
Literal-`Node`) zurück in `env` geschrieben.
|
||||
- Eine `forLoop` löst ihre (substituierte) Quelle über `rangeOrCellToCell-
|
||||
Nodes()` in eine konkrete Zellliste auf (`expandRange`, wie
|
||||
`evalArg` es für ein Bereichsargument tut) – ein Einzelzellbezug ergibt
|
||||
genau eine Iteration – und führt `body` (ein vollständiger, ggf.
|
||||
verschachtelter `BlockNode`) einmal je Zelle aus, in Zellreihenfolge.
|
||||
Schleifenvariable und alle im Körper zugewiesenen Namen bleiben danach in
|
||||
`env` (flacher Namensraum: sichtbar mit ihrem letzten Wert für den Rest der
|
||||
Ausführung).
|
||||
- `return` wirft ein `ReturnSignal` (Wert), das nur `evaluateBlockCall`
|
||||
fängt – ein Standard-"Unwind zu einer bekannten Grenze"-Muster für frühen
|
||||
Ausstieg. Das bricht **sofort** jede laufende Schleife/jedes `IF` ab, egal
|
||||
wie tief verschachtelt.
|
||||
- Läuft die Ausführung durch, ohne dass je `ReturnSignal` geworfen wurde,
|
||||
wirft `evaluateBlockCall` selbst einen `Error` ("… endet ohne RETURN").
|
||||
|
||||
Jeder `Error` hier (Schritt-Limit, ungültige FOR-Quelle, kein `RETURN`
|
||||
erreicht) propagiert unverändert zu `evaluateCell`s bestehendem catch-all in
|
||||
`workbook.ts` → `#ERR` – dieselbe Absicherung, die schon jeden anderen
|
||||
Auswertungsfehler abfängt, keine neue Fehlerbehandlung nötig.
|
||||
|
||||
**Kein Endlos-Risiko trotz echter Schleife:** `MAX_LOOP_STEPS` (10.000,
|
||||
Summe aller Iterationen einer `evaluateBlockCall`-Ausführung, auch über
|
||||
verschachtelte Schleifen hinweg) wirft bei Überschreitung. Eine Kette
|
||||
verschachtelter `kind: 'block'`-Aufrufe bleibt zusätzlich durch
|
||||
`MAX_EXPANSION_DEPTH` (32, unverändert, greift beim Auflösen verschachtelter
|
||||
Aufrufe in `expandBlock`) begrenzt, sodass die Gesamtarbeit pro Formel immer
|
||||
endlich bleibt.
|
||||
|
||||
**Abhängigkeiten:** `collectRefs()` (`parser.ts`) bekommt einen `blockcall`-
|
||||
Fall, der sowohl `.args` als auch – über die neue Hilfsfunktion
|
||||
`collectRefsInBlock` – jeden im Block erreichbaren Zellbezug einsammelt,
|
||||
rekursiv durch `FOR`/`IF`. Das ist bewusst **konservativ**: ein Bezug in
|
||||
einem zur Laufzeit nicht genommenen `IF`-Zweig zählt trotzdem als
|
||||
Abhängigkeit (genau wie bei einem gewöhnlichen `IF` in Excel) – lieber
|
||||
gelegentlich unnötig neu rechnen als je einen veralteten Wert zeigen.
|
||||
|
||||
**Namens-Validierung ist Store-Sache, nicht Engine-Sache** (wie beim
|
||||
Fehlermodell durchgängig: die Engine liefert Mechanik, deutsche
|
||||
Fehlertexte entstehen im Store). `userFunctions.ts` exportiert dafür,
|
||||
jeweils rekursiv durch verschachtelte `FOR`/`IF`-Körper:
|
||||
|
||||
- `declaredBlockNames(block)` – jeder eingeführte Name, inkl. Wiederholungen
|
||||
bei Wiederverwendung (z. B. das Akkumulator-Muster `ergebnis = 1 FOR x IN
|
||||
bereich DO ergebnis = ergebnis * x END`, wo „ergebnis" zweimal auftaucht,
|
||||
aber **keine** Kollision ist).
|
||||
- `findInvalidForSource(block)` – die erste `FOR`-Quelle, die syntaktisch
|
||||
weder Parameter noch Zelle/Bereich ist (egal wie tief verschachtelt).
|
||||
- `collectCalledFunctionNamesInBlock(block)` – läuft das bestehende
|
||||
`collectCalledFunctionNames()` über jeden Blatt-Ausdruck des Blocks; von
|
||||
der Store-Zyklusprüfung genutzt.
|
||||
- `blockAlwaysReturns(block)` – konservative statische Prüfung "erreicht
|
||||
jeder Pfad garantiert ein `RETURN`": wahr, wenn der Block eine
|
||||
`return`-Anweisung enthält, oder ein `IF` mit `ELSE`, bei dem **beide**
|
||||
Zweige immer returnen. Eine `FOR`-Schleife allein reicht nie (könnte 0
|
||||
Iterationen haben) – das ist eine Frühwarnung beim Speichern, die
|
||||
Laufzeit-Prüfung oben ("… endet ohne RETURN" → `#ERR`) ist die tatsächliche
|
||||
Absicherung.
|
||||
|
||||
`document.ts`s `validateCustomFunction` prüft damit: jeder Name gültig/nicht
|
||||
zellartig/kein reserviertes Wort (`FOR`/`IN`/`DO`/`END`/`IF`/`THEN`/`ELSE`/
|
||||
`RETURN`), kein *unterschiedlicher* lokaler Name überschattet einen
|
||||
Parameter (Wiederverwendung *desselben* Namens über mehrere Anweisungen ist
|
||||
dagegen erlaubt), `blockAlwaysReturns` sowie die Zyklus-Prüfung.
|
||||
|
||||
## Auswertungsmodell
|
||||
|
||||
- **Arithmetik ist numerisch.** Leere Zelle (`null`) zählt als `0`. Eine
|
||||
zahlenförmige Zeichenkette wird gecastet, jede andere ergibt `#VALUE!`.
|
||||
- **Vergleiche** (`= <> > < >= <=`), Ergebnis `1` (wahr) oder `0` (falsch);
|
||||
genutzt vor allem in einer `IF`-Bedingung eines „Mit Schleife"-
|
||||
Funktionskörpers (s. o.), aber in jeder Formel gültig. Zwei Zahlen (oder
|
||||
eine leere Zelle, die als `0` zählt) vergleichen **numerisch**
|
||||
(`toNumber()`-Koerzierung wie bei `+ - * /`); zwei Texte (oder eine leere
|
||||
Zelle, die dann als `''` zählt) vergleichen **text**uell –
|
||||
case-insensitiv und getrimmt, dieselbe Konvention wie `XLOOKUP`s
|
||||
Text-Matching (s. u.), bewusst als eine einzige Regel im Motor statt
|
||||
zweier verschiedener. Eine leere Zelle nimmt dabei immer den Typ der
|
||||
**anderen** Seite an, sodass sowohl `=A1=0` als auch `=A1=""` auf einer
|
||||
echt leeren `A1` funktionieren. Eine echte (nicht-leere) Zahl gegen
|
||||
echten Text ist ein **Typ-Mismatch**, kein Crossmatch: `=`/`<>`
|
||||
antworten eindeutig falsch/wahr, `> < >= <=` haben keine sinnvolle
|
||||
Antwort und ergeben `#VALUE!` (siehe `evaluator.ts`s `compareValues()`).
|
||||
- **`&` verkettet als Text** (`toText()`): eine Zahl rendert `String(n)`
|
||||
ohne Locale-Formatierung (kein `Intl`, dieselbe „locale-neutral"-
|
||||
Konvention wie der CSV-/XLSX-Export), eine leere Zelle trägt `''` bei
|
||||
(nicht `'0'`). Präzedenz zwischen `+ -` und einem Vergleich (s. o. EBNF),
|
||||
links-assoziativ. `CONCAT`/`CONCATENATE(Text1; Text2; …)` sind das
|
||||
Funktions-Äquivalent (`functions.ts`), ohne Operator-Präzedenz.
|
||||
- **Fehler propagieren unverändert** durch Operatoren und Funktionen (der erste
|
||||
gefundene Fehler gewinnt).
|
||||
- Ein **Bereich** darf nur als Funktionsargument stehen. In Skalar-Position
|
||||
ergibt er `#VALUE!`.
|
||||
- Ein **`NamedRefNode`** (benannter Bereich) löst live gegen
|
||||
`host.resolveNamedRange()` auf und verhält sich danach exakt wie die
|
||||
Zelle/der Bereich, die er dort ergibt (inkl. der Skalar-Regel oben für
|
||||
einen mehrzelligen Treffer). Unbekannter Name → `#NAME?`. Details:
|
||||
„Benannte Bereiche" oben.
|
||||
- `SUM/MIN/MAX` über „keine Zahlen“ → `0`; `AVG` über „keine Zahlen“ → `#DIV/0!`.
|
||||
- `COUNT` zählt nur echte Zahlen – leere Zellen und Text werden nicht gezählt.
|
||||
- `^` = `Math.pow`, rechts-assoziativ: `=2^3^2` → `2^(3^2)` = `512`.
|
||||
- **`IF(Bedingung; Dann; Sonst?)` ist die einzige Funktion, die nicht eager
|
||||
ausgewertet wird.** Jede andere Funktion sieht in `functions.ts` bereits
|
||||
fertig berechnete `CellValue`s (der Evaluator wertet vorher jedes Argument
|
||||
aus und short-circuited beim ersten Fehler); `IF` wird stattdessen schon in
|
||||
`evalScalar`s `case 'func':` abgefangen, bevor `functions.ts` je erreicht
|
||||
wird (`evalIf()`): nur die Bedingung wird immer ausgewertet, danach
|
||||
ausschließlich der genommene Zweig – der andere Zweig wird gar nicht erst
|
||||
evaluiert, ein Fehler darin schlägt also nie durch (klassisch:
|
||||
`=IF(B1=0; 0; A1/B1)`). Ohne Sonst-Zweig liefert eine falsche Bedingung
|
||||
`0`. `collectRefs()` bleibt für `IF` bewusst generisch/konservativ (läuft
|
||||
durch alle 2–3 Argumente unabhängig vom Laufzeit-Zweig) – dieselbe
|
||||
Konvention wie bei `kind: 'block'`s `IF`-Abhängigkeiten (s. u.). Ein
|
||||
benutzerdefiniertes „IF" ist nicht anlegbar (Namenskollision mit dem
|
||||
eingebauten `FUNCTIONS.IF`-Eintrag).
|
||||
- **`AND`/`OR`/`NOT` brauchen dagegen keine Sonderbehandlung** — anders als
|
||||
`IF`/`XLOOKUP` sind es ganz normale `functions.ts`-Einträge, ohne
|
||||
`evaluator.ts`-Kurzschluss: `AND`/`OR` werten (wie in echtem Excel) **alle**
|
||||
Argumente eager aus, ein Fehler in irgendeinem Argument propagiert also
|
||||
immer, egal ob das logische Ergebnis schon feststeht (`=AND(1=2; 1/0)` →
|
||||
`#DIV/0!`, nicht `0`). `functions.ts#isTruthy()` ist die eine
|
||||
Wahrheits-Konvention, die überall im Motor geteilt wird, wo eine Bedingung
|
||||
gebraucht wird – `evalIf()` und `execStatement`s `ifstmt`-Fall importieren
|
||||
sie jetzt aus `functions.ts`, statt sie (wie vor diesem Feature) je einmal
|
||||
inline zu duplizieren.
|
||||
|
||||
## Datum & Zeit
|
||||
|
||||
Kein eigener Zellwert-Typ — ein Datum ist wie in jeder Tabellenkalkulation
|
||||
einfach eine **Zahl** (`dates.ts`): das "Serial", Tage seit dem Nullpunkt
|
||||
**1899-12-30** (Google-Sheets-Konvention: wie Excel, aber ohne dessen
|
||||
historischen 1900-Schaltjahr-Bug), Uhrzeit als Bruchteil eines Tages
|
||||
(`0.5` = 12:00). Serial 1 = 1899-12-31, Serial 2 = 1900-01-01. Alle
|
||||
Berechnungen laufen über UTC-Felder (`Date.UTC`/`getUTC*`), damit ein Serial
|
||||
unabhängig von der Browser-Zeitzone exakt rundtrippt. Weil ein Datum einfach
|
||||
eine Zahl ist, funktionieren `+`/`-`/Vergleiche darauf automatisch — keine
|
||||
Änderung an Operatoren nötig.
|
||||
|
||||
- **`dateToSerial(y, mo, d, h?, mi?, s?)`** — Baustein für `DATE()`, reines
|
||||
`Date.UTC`, **Überlauf bewusst erlaubt** (`dateToSerial(2026, 13, 1)` =
|
||||
2027-01-01), wie Excels `DATE()`.
|
||||
- **`timeSerial(h, mi, s)`** — Baustein für `TIME()`, wrapt modulo 86400
|
||||
Sekunden wie Excels `TIME()` (`timeSerial(25,0,0)` = der `1/24`-Anteil des
|
||||
Folgetags).
|
||||
- **`todaySerial()`/`nowSerial()`** — nutzen das **lokale** Kalenderdatum
|
||||
(ein Datum ist ein Kalender-, kein Zeitzonen-Konzept), nicht UTC.
|
||||
- **`parseDateTimeLiteral(raw)`** — erkennt eine rohe Zelleingabe als Datum/
|
||||
Zeit/Datum+Zeit, genutzt sowohl von `workbook.ts`s `literalValue()` (Text →
|
||||
Serial) als auch vom Store (`document.ts`s `setCellInput`, Auto-Format
|
||||
beim Eintippen, s. u.). **Bewusst konservativ**: nur ISO (`2026-09-02`,
|
||||
optional `[ T]HH:MM[:SS]`) und Punkt-Daten (`02.09.2026`, `02.09.26` —
|
||||
2-stellig: 00–29 → 20xx, 30–99 → 19xx —, ebenfalls mit Uhrzeit) sowie reine
|
||||
`HH:MM[:SS]`-Zeiten werden erkannt. **Schrägstrich-Daten (`02/09/2026`)
|
||||
absichtlich nicht** — Tag-zuerst vs. Monat-zuerst ist über die
|
||||
unterstützten Locales (en-US monatzuerst, alle anderen tagzuerst) nicht
|
||||
eindeutig auflösbar; falsches Raten würde das Datum lautlos verfälschen,
|
||||
das bleibt schlechter als es als Text stehen zu lassen. Ein intern über
|
||||
`Date.UTC` gebauter Kandidat wird durch Rück-Dekomposition verifiziert, um
|
||||
stillen Überlauf abzulehnen (`31.04.2026`, `30.02.2026` → **kein** Treffer,
|
||||
bleibt Text) — anders als `dateToSerial`/`DATE()`, die Überlauf bewusst
|
||||
zulassen.
|
||||
- **`DATE`/`TIME`/`TODAY`/`NOW`** (`functions.ts`) — `DATE`/`TIME` erwarten
|
||||
genau 3 Argumente (sonst `#ERR`), koerzieren jedes wie `evaluator.ts`s
|
||||
`toNumber()` (null → 0, zahlenförmiger Text gecastet, sonst `#VALUE!`);
|
||||
`TODAY`/`NOW` erwarten 0 Argumente. **Nicht volatil**: diese Engine
|
||||
berechnet rein vorgänger-getrieben neu, ohne Timer/Hintergrundarbeit — eine
|
||||
Zelle mit `=TODAY()`/`=NOW()` aktualisiert sich erst bei ihrer eigenen
|
||||
nächsten Neuberechnung, nicht spontan mit der Zeit (dokumentierte Grenze).
|
||||
- **Anzeige** (`store/format.ts`) — `CellNumFmt`-Werte `date`/`time`/
|
||||
`datetime` rendern über `serialToUtcDate()` + `Intl.DateTimeFormat(locale,
|
||||
{ timeZone: 'UTC', ... })`; das Pseudo-Locale `'plain'` bekommt eine
|
||||
manuelle, locale-neutrale `YYYY-MM-DD`/`HH:MM`-Formatierung (bewusst nicht
|
||||
`Intl` mit `undefined`-Locale, das würde lautlos die Host-Locale des
|
||||
Browsers durchsickern lassen). Formel-Ergebnisse bekommen **kein**
|
||||
automatisches Format — wie jedes andere Formelergebnis auch. Ein viertes
|
||||
`CellNumFmt` — `dateiso` — rendert dasselbe `YYYY-MM-DD` **unabhängig von
|
||||
der Locale** (nutzt intern dieselbe Formatier-Funktion wie `'plain'`s
|
||||
Datumsanteil), für export-nahe/sortierbare Datumsspalten, die sich nicht
|
||||
mit der Blatt-/Dokument-Locale mitändern sollen.
|
||||
- **Auto-Format beim Eintippen** (`store/document.ts`s `setCellInput`) — eine
|
||||
erkannte Literal-Eingabe (nicht `=`-Formeln) setzt automatisch das
|
||||
passende `numFmt`, aber **nur** solange die Zelle noch `'auto'` ist; ein
|
||||
bereits explizit gesetztes Format wird nie überschrieben.
|
||||
|
||||
## Leere Argumentstelle (`OmittedNode`)
|
||||
|
||||
`funcall`s Grammatik (s. o.) erlaubt jetzt eine leere Stelle zwischen zwei
|
||||
`;` (oder zwischen einem `;` und dem schließenden `)`) statt eines
|
||||
Ausdrucks — Excels "dieses optionale Argument einfach auslassen"-Idiom,
|
||||
z. B. `XLOOKUP(x; a; b; ; -1)`, das `WennNichtGefunden` überspringt, um
|
||||
`Vergleichsmodus` zu erreichen. Der Parser produziert dafür einen eigenen
|
||||
`OmittedNode` (Teil des `Node`-Unions, s. o.) statt eines Parse-Fehlers.
|
||||
`evalScalar` wertet ihn wie eine leere Zelle aus (`null`) — für die meisten
|
||||
Funktionen (`SUM`, `COUNT`, …) verhält er sich also genauso wie eine echte
|
||||
leere Zelle in einem Bereich. Eine Funktion, die "explizit ausgelassen"
|
||||
von "wertet zu leer aus" unterscheiden muss (nur `XLOOKUP`, bisher), prüft
|
||||
`arg.kind === 'omitted'` auf dem rohen Argument-Knoten, **bevor** sie ihn
|
||||
auswertet (`evalXLookup`s `given()`-Hilfsfunktion in `evaluator.ts`).
|
||||
`expand()`/`substitute()` (`userFunctions.ts`) behandeln ihn als Blatt ohne
|
||||
weitere Rekursion, `collectRefs()` ignoriert ihn stillschweigend (kein
|
||||
Bezug enthalten).
|
||||
|
||||
## Nachschlagen (XLOOKUP)
|
||||
|
||||
`XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
|
||||
[Vergleichsmodus]; [Suchmodus])` ist – wie `IF` – eine Funktion, die
|
||||
`evalScalar`s `case 'func':` **vor** dem generischen eager/flatten-Pfad
|
||||
abfängt (`evalXLookup()`), aus zwei Gründen, nicht nur einem:
|
||||
|
||||
1. **`WennNichtGefunden` muss lazy ausgewertet werden** — nur wenn
|
||||
tatsächlich kein Treffer gefunden wurde (derselbe Kurzschluss-Grundsatz
|
||||
wie bei `IF`s Zweigen).
|
||||
2. **`Suchbereich` und `Rückgabebereich` dürfen nicht zusammen flach
|
||||
geklopft werden.** Der generische Pfad (jede Funktion außer `IF` und
|
||||
`XLOOKUP`) wertet jedes Argument über `evalArg()` aus und hängt alle
|
||||
Ergebnisse an **eine gemeinsame** flache Liste an (siehe
|
||||
`functions.ts`s Modul-Kommentar) — dabei geht die Grenze zwischen zwei
|
||||
Bereichs-Argumenten komplett verloren. `evalArg()` selbst liefert pro
|
||||
Argument aber bereits ein eigenes Array zurück; `evalXLookup()` ruft es
|
||||
für `Suchbereich` und `Rückgabebereich` separat auf und hält die beiden
|
||||
Arrays getrennt, statt sie (wie der generische Pfad) zusammenzuführen.
|
||||
|
||||
Trefferlogik: Text wird über eine eigene `xlookupEquals()` verglichen
|
||||
(case-insensitiv, getrimmt) statt über den `=`-Operator (der über
|
||||
`toNumber()` koerziert und deshalb nie auf Text träfe – XLOOKUPs
|
||||
häufigster Anwendungsfall). Zahl und Text gelten nie als gleich. Bei
|
||||
`Vergleichsmodus -1`/`1` gewinnt ein exakter Treffer immer sofort, sonst
|
||||
wird der nächstkleinere/-größere **numerische** Kandidat gesucht (Text
|
||||
wird dabei übersprungen, nicht verglichen). `Suchmodus -1` kehrt einfach
|
||||
die Durchlaufreihenfolge um (kein Binärsuche-Algorithmus, kein sortierter
|
||||
Bereich vorausgesetzt).
|
||||
|
||||
`#N/A` (kein Treffer, kein `WennNichtGefunden`) ist eine dokumentierte
|
||||
Erweiterung des ursprünglichen 5-Fehlercode-Sets, mit derselben Begründung
|
||||
wie seinerzeit `#VALUE!`. `collectRefs()` braucht **keine** Änderung –
|
||||
sein generischer `case 'func':` läuft bereits durch alle Argumente
|
||||
(inkl. `Suchbereich`/`Rückgabebereich`), unabhängig davon, ob ein Treffer
|
||||
gefunden wird — dieselbe konservative Konvention wie bei `IF`.
|
||||
|
||||
## Neuberechnung
|
||||
|
||||
1. `setCell` reparst **eine** Zelle, aktualisiert ihre Graph-Kanten.
|
||||
2. `graph.affected(dirty)` = geänderte Zellen + alle transitiven Abhängigen.
|
||||
3. `graph.topoOrder(affected)` liefert eine sichere Auswertungsreihenfolge
|
||||
(Präzedenzen zuerst) **und** die Menge der Zellen auf einem Zyklus.
|
||||
4. Zyklus-Zellen bekommen direkt `#CIRC`; der Rest wird in topologischer
|
||||
Reihenfolge ausgewertet. Nicht betroffene Zellen behalten ihren Wert
|
||||
(„nur betroffene Zellen neu rechnen“).
|
||||
|
||||
Strukturänderungen (Tabelle umbenennen/löschen, Zeilen/Spalten, Blatt löschen)
|
||||
rufen `rebuild()` für einen vollständigen – trotzdem billigen – Durchlauf.
|
||||
|
||||
**`Workbook.graphPrecedents`/`graphDependents`** (dünne öffentliche Wrapper
|
||||
um `DependencyGraph.getPrecedents`/`getDependents`) sind der einzige Ort, an
|
||||
dem der Graph *abgefragt* statt nur für die Neuberechnung *benutzt* wird –
|
||||
die Datenquelle des interaktiven Abhängigkeiten-Panels (`src/store/
|
||||
document.ts`s `cellGraphNeighbors`/`cellGraphSelf`, `src/components/
|
||||
DependencyGraphView.vue`, s. Haupt-README „Abhängigkeiten erkunden"). Rein
|
||||
lesend, ohne jeden Effekt auf Auswertung/Neuberechnung; bleiben auch für
|
||||
eine Zelle auf einem Zirkelbezug korrekt (die Kante existiert im Graphen
|
||||
unabhängig davon, dass `topoOrder` sie aus der Auswertung ausschließt).
|
||||
|
||||
## Fehlermodell
|
||||
|
||||
| Wert | Bedeutung |
|
||||
|------------|-----------|
|
||||
| `#REF` | ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, **oder** ein unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch über die "gleiches Blatt wie die Formel"-Regel nicht eindeutig wird |
|
||||
| `#DIV/0!` | Division durch 0 (auch `AVG` ohne Zahlen) |
|
||||
| `#NAME?` | unbekannte Funktion (eingebaut oder benutzerdefiniert) |
|
||||
| `#ERR` | Parse-Fehler (`=A1+`, `=SUM(`); auch Arity-Mismatch/Expansionstiefen-Limit bei einer benutzerdefinierten Funktion, oder bei `kind: 'block'` eine ungültige FOR-Quelle, mehr als `MAX_LOOP_STEPS` Schleifenschritte, oder eine Ausführung ohne je erreichtes `RETURN` |
|
||||
| `#CIRC` | Zirkelbezug – auch tabellen-/blattübergreifend |
|
||||
| `#VALUE!` | Typfehler in Arithmetik (Text × Zahl, Bereich als Skalar); auch `XLOOKUP` mit ungleich großem Such-/Rückgabebereich. *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
|
||||
| `#N/A` | `XLOOKUP` ohne Treffer und ohne `WennNichtGefunden`. *Erweiterung über die Aufgabenstellung hinaus, dokumentiert.* |
|
||||
|
||||
Es gibt **keinen** Absturz und **keinen** Hang: Lexer/Parser werfen intern,
|
||||
`Workbook` fängt alles ab und schreibt einen Fehlerwert in die Zelle.
|
||||
|
||||
Jeder dieser sieben Codes ist außerdem als **`ErrorLiteralNode`** direkt in
|
||||
eine Formel tippbar (`=#REF`, `=IF(A1=0;#N/A;A1)`, …) – der Lexer erkennt
|
||||
ihn als eigenes `ERRORLIT`-Token (längster Treffer zuerst, s. o. EBNF),
|
||||
`evalScalar`s `case 'errorliteral':` gibt unbedingt `new FormulaError(code)`
|
||||
zurück. `store/document.ts`s Zeilen-/Spalten-Löschen ist der eine Ort, der
|
||||
selbst *Formeltext* mit `#REF`-Literalen erzeugt (für eine dadurch
|
||||
kollabierte Referenz) – ansonsten ist es reine Nutzer-Eingabe, wie eine
|
||||
Zahl- oder Text-Konstante. Kein `!` am Ende (`#REF`, nicht Excels `#REF!`)
|
||||
– derselbe Code, den diese Engine selbst überall sonst produziert.
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FUNCTIONS } from '../functions';
|
||||
import { dateToSerial, timeSerial } from '../dates';
|
||||
import { isError, type CellValue } from '../types';
|
||||
|
||||
const call = (name: string, ...args: CellValue[]) => FUNCTIONS[name](args);
|
||||
const d = (y: number, m: number, day: number) => dateToSerial(y, m, day);
|
||||
|
||||
describe('date parts', () => {
|
||||
const t = dateToSerial(2026, 9, 2, 14, 30, 15);
|
||||
|
||||
it('YEAR / MONTH / DAY / HOUR / MINUTE / SECOND', () => {
|
||||
expect(call('YEAR', t)).toBe(2026);
|
||||
expect(call('MONTH', t)).toBe(9);
|
||||
expect(call('DAY', t)).toBe(2);
|
||||
expect(call('HOUR', t)).toBe(14);
|
||||
expect(call('MINUTE', t)).toBe(30);
|
||||
expect(call('SECOND', t)).toBe(15);
|
||||
});
|
||||
|
||||
it('rounds to the whole second, so float noise never shows as :59', () => {
|
||||
const noisy = timeSerial(14, 29, 59) + 1 / 86400;
|
||||
expect(call('HOUR', noisy)).toBe(14);
|
||||
expect(call('MINUTE', noisy)).toBe(30);
|
||||
expect(call('SECOND', noisy)).toBe(0);
|
||||
});
|
||||
|
||||
it('checks argument count and type', () => {
|
||||
expect(isError(call('YEAR'))).toBe(true);
|
||||
expect(isError(call('YEAR', 1, 2))).toBe(true);
|
||||
expect(isError(call('YEAR', 'kein Datum'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WEEKDAY (ISO default: Monday = 1)', () => {
|
||||
const wed = d(2026, 9, 2);
|
||||
it('defaults to ISO numbering', () => {
|
||||
expect(call('WEEKDAY', wed)).toBe(3);
|
||||
expect(call('WEEKDAY', d(2026, 9, 6))).toBe(7); // Sunday
|
||||
});
|
||||
it('supports Excel types 1, 2, 3', () => {
|
||||
expect(call('WEEKDAY', wed, 1)).toBe(4); // Sunday = 1
|
||||
expect(call('WEEKDAY', wed, 2)).toBe(3);
|
||||
expect(call('WEEKDAY', wed, 3)).toBe(2); // Monday = 0
|
||||
expect(isError(call('WEEKDAY', wed, 7))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ISO week numbers', () => {
|
||||
it('handles the year boundaries', () => {
|
||||
expect(call('ISOWEEKNUM', d(2026, 1, 1))).toBe(1); // Thursday
|
||||
expect(call('ISOWEEKNUM', d(2027, 1, 1))).toBe(53); // Friday -> last week of 2026
|
||||
expect(call('ISOWEEKNUM', d(2021, 1, 3))).toBe(53); // Sunday -> week 53 of 2020
|
||||
expect(call('ISOWEEKNUM', d(2024, 12, 30))).toBe(1); // Monday -> week 1 of 2025
|
||||
expect(call('ISOWEEKNUM', d(2026, 9, 2))).toBe(36);
|
||||
});
|
||||
it('WEEKNUM is ISO by default, US-style with type 1/2', () => {
|
||||
expect(call('WEEKNUM', d(2021, 1, 3))).toBe(53);
|
||||
expect(call('WEEKNUM', d(2026, 1, 3), 1)).toBe(1); // Saturday, same week as 1 Jan
|
||||
expect(call('WEEKNUM', d(2026, 1, 4), 1)).toBe(2); // Sunday starts week 2
|
||||
expect(call('WEEKNUM', d(2026, 1, 4), 2)).toBe(1); // ... but not when weeks start Monday
|
||||
expect(call('WEEKNUM', d(2026, 1, 5), 2)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('month arithmetic', () => {
|
||||
it('EDATE clamps to the end of a shorter month', () => {
|
||||
expect(call('EDATE', d(2026, 1, 31), 1)).toBe(d(2026, 2, 28));
|
||||
expect(call('EDATE', d(2024, 1, 31), 1)).toBe(d(2024, 2, 29));
|
||||
expect(call('EDATE', d(2026, 3, 31), -1)).toBe(d(2026, 2, 28));
|
||||
expect(call('EDATE', d(2026, 9, 2), 12)).toBe(d(2027, 9, 2));
|
||||
});
|
||||
it('EOMONTH', () => {
|
||||
expect(call('EOMONTH', d(2026, 9, 2), 0)).toBe(d(2026, 9, 30));
|
||||
expect(call('EOMONTH', d(2026, 9, 2), -1)).toBe(d(2026, 8, 31));
|
||||
expect(call('EOMONTH', d(2026, 1, 15), 1)).toBe(d(2026, 2, 28));
|
||||
});
|
||||
});
|
||||
|
||||
describe('differences', () => {
|
||||
it('DAYS counts whole calendar days, ignoring the time', () => {
|
||||
expect(call('DAYS', d(2026, 12, 31), d(2026, 1, 1))).toBe(364);
|
||||
expect(call('DAYS', d(2026, 1, 2) + 0.9, d(2026, 1, 1) + 0.1)).toBe(1);
|
||||
});
|
||||
it('DATEDIF in every unit', () => {
|
||||
const s = d(2020, 2, 29);
|
||||
const e = d(2026, 9, 2);
|
||||
expect(call('DATEDIF', s, e, 'Y')).toBe(6);
|
||||
expect(call('DATEDIF', s, e, 'M')).toBe(78);
|
||||
expect(call('DATEDIF', s, e, 'D')).toBe(e - s);
|
||||
expect(call('DATEDIF', s, e, 'YM')).toBe(6);
|
||||
expect(call('DATEDIF', s, e, 'MD')).toBe(4); // 29.08. -> 02.09.
|
||||
expect(call('DATEDIF', s, e, 'yd')).toBe(d(2026, 9, 2) - d(2026, 2, 28));
|
||||
expect(isError(call('DATEDIF', e, s, 'D'))).toBe(true);
|
||||
expect(isError(call('DATEDIF', s, e, 'W'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('working days (Monday-Friday)', () => {
|
||||
it('NETWORKDAYS counts both ends and skips holidays on weekdays', () => {
|
||||
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30))).toBe(22);
|
||||
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30), d(2026, 9, 15))).toBe(21);
|
||||
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30), d(2026, 9, 5))).toBe(22); // Saturday
|
||||
expect(call('NETWORKDAYS', d(2026, 9, 30), d(2026, 9, 1))).toBe(-22);
|
||||
});
|
||||
it('WORKDAY steps over weekends and holidays, both directions', () => {
|
||||
expect(call('WORKDAY', d(2026, 9, 4), 1)).toBe(d(2026, 9, 7)); // Fri -> Mon
|
||||
expect(call('WORKDAY', d(2026, 9, 7), -1)).toBe(d(2026, 9, 4));
|
||||
expect(call('WORKDAY', d(2026, 9, 4), 1, d(2026, 9, 7))).toBe(d(2026, 9, 8));
|
||||
expect(call('WORKDAY', d(2026, 9, 2), 0)).toBe(d(2026, 9, 2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DATEVALUE / TIMEVALUE', () => {
|
||||
it('read the same formats a cell entry is recognized in', () => {
|
||||
expect(call('DATEVALUE', '2026-09-02')).toBe(d(2026, 9, 2));
|
||||
expect(call('DATEVALUE', '02.09.2026 14:30')).toBe(d(2026, 9, 2));
|
||||
expect(call('TIMEVALUE', '14:30')).toBeCloseTo(timeSerial(14, 30, 0), 12);
|
||||
expect(call('TIMEVALUE', '2026-09-02 14:30')).toBeCloseTo(timeSerial(14, 30, 0), 12);
|
||||
});
|
||||
it('pass a number through and reject other text', () => {
|
||||
expect(call('DATEVALUE', d(2026, 9, 2) + 0.5)).toBe(d(2026, 9, 2));
|
||||
expect(isError(call('DATEVALUE', 'hallo'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
dateToSerial,
|
||||
timeSerial,
|
||||
serialToUtcDate,
|
||||
todaySerial,
|
||||
parseDateTimeLiteral,
|
||||
} from '../dates';
|
||||
|
||||
describe('dateToSerial / serialToUtcDate', () => {
|
||||
it('epoch is 1899-12-30: serial 1 = 1899-12-31, serial 2 = 1900-01-01', () => {
|
||||
expect(dateToSerial(1899, 12, 31)).toBe(1);
|
||||
expect(dateToSerial(1900, 1, 1)).toBe(2);
|
||||
});
|
||||
|
||||
it('round-trips through serialToUtcDate', () => {
|
||||
const s = dateToSerial(2026, 9, 2);
|
||||
const d = serialToUtcDate(s);
|
||||
expect(d.getUTCFullYear()).toBe(2026);
|
||||
expect(d.getUTCMonth()).toBe(8); // 0-based
|
||||
expect(d.getUTCDate()).toBe(2);
|
||||
});
|
||||
|
||||
it('allows overflow, like Excel DATE() (month 13 -> next January)', () => {
|
||||
const s = dateToSerial(2026, 13, 1);
|
||||
const d = serialToUtcDate(s);
|
||||
expect(d.getUTCFullYear()).toBe(2027);
|
||||
expect(d.getUTCMonth()).toBe(0);
|
||||
expect(d.getUTCDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('carries a fractional part for a time-of-day component', () => {
|
||||
const s = dateToSerial(2026, 9, 2, 12, 0, 0);
|
||||
expect(s % 1).toBeCloseTo(0.5, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeSerial', () => {
|
||||
it('noon = 0.5, midnight = 0', () => {
|
||||
expect(timeSerial(12, 0, 0)).toBeCloseTo(0.5, 10);
|
||||
expect(timeSerial(0, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('wraps past 24h like Excel TIME()', () => {
|
||||
expect(timeSerial(25, 0, 0)).toBeCloseTo(1 / 24, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('todaySerial', () => {
|
||||
it('matches the local calendar date', () => {
|
||||
const n = new Date();
|
||||
expect(todaySerial()).toBe(Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate())));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDateTimeLiteral', () => {
|
||||
it('recognizes ISO dates', () => {
|
||||
const r = parseDateTimeLiteral('2026-09-02');
|
||||
expect(r).toEqual({ kind: 'date', serial: dateToSerial(2026, 9, 2) });
|
||||
});
|
||||
|
||||
it('recognizes ISO datetimes (space or T separator, optional seconds)', () => {
|
||||
expect(parseDateTimeLiteral('2026-09-02 14:30')).toEqual({
|
||||
kind: 'datetime',
|
||||
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
|
||||
});
|
||||
expect(parseDateTimeLiteral('2026-09-02T14:30:15')).toEqual({
|
||||
kind: 'datetime',
|
||||
serial: dateToSerial(2026, 9, 2, 14, 30, 15),
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes dot dates, 4- and 2-digit years', () => {
|
||||
expect(parseDateTimeLiteral('02.09.2026')).toEqual({
|
||||
kind: 'date',
|
||||
serial: dateToSerial(2026, 9, 2),
|
||||
});
|
||||
expect(parseDateTimeLiteral('02.09.26')).toEqual({
|
||||
kind: 'date',
|
||||
serial: dateToSerial(2026, 9, 2),
|
||||
});
|
||||
expect(parseDateTimeLiteral('02.09.99')).toEqual({
|
||||
kind: 'date',
|
||||
serial: dateToSerial(1999, 9, 2),
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes dot datetimes', () => {
|
||||
expect(parseDateTimeLiteral('02.09.2026 14:30')).toEqual({
|
||||
kind: 'datetime',
|
||||
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes plain times', () => {
|
||||
expect(parseDateTimeLiteral('14:30')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 0) });
|
||||
expect(parseDateTimeLiteral('14:30:15')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 15) });
|
||||
});
|
||||
|
||||
it('does NOT recognize slash-separated dates (day-first/month-first ambiguity)', () => {
|
||||
expect(parseDateTimeLiteral('02/09/2026')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an out-of-range or rolled-over date instead of silently guessing', () => {
|
||||
expect(parseDateTimeLiteral('31.04.2026')).toBeNull(); // April has 30 days
|
||||
expect(parseDateTimeLiteral('30.02.2026')).toBeNull(); // no Feb 30
|
||||
expect(parseDateTimeLiteral('2026-13-01')).toBeNull(); // month 13
|
||||
});
|
||||
|
||||
it('rejects an out-of-range minute/second', () => {
|
||||
expect(parseDateTimeLiteral('12:60')).toBeNull();
|
||||
expect(parseDateTimeLiteral('37:30:60')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads 24 hours or more as a duration, not a time of day', () => {
|
||||
expect(parseDateTimeLiteral('25:00')).toEqual({ kind: 'duration', serial: 25 / 24 });
|
||||
expect(parseDateTimeLiteral('37:30')).toEqual({ kind: 'duration', serial: 37.5 / 24 });
|
||||
expect(parseDateTimeLiteral('100:00:30')!.serial).toBeCloseTo((100 * 3600 + 30) / 86400, 12);
|
||||
expect(parseDateTimeLiteral('23:59')!.kind).toBe('time');
|
||||
});
|
||||
|
||||
it('does not misfire on plain numbers or arbitrary text', () => {
|
||||
expect(parseDateTimeLiteral('1234.5')).toBeNull();
|
||||
expect(parseDateTimeLiteral('hello')).toBeNull();
|
||||
expect(parseDateTimeLiteral('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFormula } from '../parser';
|
||||
import { evaluate } from '../evaluator';
|
||||
import { FormulaError, isError, type CellValue, type EngineHost } from '../types';
|
||||
|
||||
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
|
||||
|
||||
/** Minimal in-memory host: two tables addressed by name, plus an optional
|
||||
* name -> named-range map (name lower-cased, same matching convention as
|
||||
* the real Workbook). */
|
||||
function makeHost(
|
||||
data: Record<string, Record<string, CellValue>>,
|
||||
home = 'S1',
|
||||
namedRanges: Record<string, NamedRangeShape> = {},
|
||||
): EngineHost {
|
||||
const sizes: Record<string, { cols: number; rows: number }> = {
|
||||
S1: { cols: 10, rows: 10 },
|
||||
S2: { cols: 10, rows: 10 },
|
||||
};
|
||||
const idFor = (name: string): string | null => {
|
||||
const n = name.trim().toLowerCase();
|
||||
if (n === 's1') return 'S1';
|
||||
if (n === 's2') return 'S2';
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
homeTableId: home,
|
||||
resolveTableName: idFor,
|
||||
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
|
||||
tableSize: (id) => sizes[id] ?? null,
|
||||
getComputed(tableId, col, row) {
|
||||
const cell = String.fromCharCode(65 + col) + (row + 1);
|
||||
const v = data[tableId]?.[cell];
|
||||
return v === undefined ? null : v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ev = (src: string, host: EngineHost) => evaluate(parseFormula(src), host);
|
||||
|
||||
describe('evaluator arithmetic', () => {
|
||||
const host = makeHost({ S1: { B2: 10, C2: 5, D2: 8 } });
|
||||
|
||||
it('respects precedence and associativity', () => {
|
||||
expect(ev('=(B2+C2)*2-D2/4', host)).toBe(28); // (15)*2 - 2
|
||||
expect(ev('=2^3^2', host)).toBe(512);
|
||||
expect(ev('=-2^2', host)).toBe(-4);
|
||||
expect(ev('=2+3*4', host)).toBe(14);
|
||||
});
|
||||
|
||||
it('treats an empty cell as 0', () => {
|
||||
expect(ev('=B2+Z9', host)).toBe(10);
|
||||
});
|
||||
|
||||
it('division by zero -> #DIV/0!', () => {
|
||||
expect(ev('=1/0', host)).toEqual(new FormulaError('#DIV/0!'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator functions', () => {
|
||||
const host = makeHost({
|
||||
S1: { B2: 1200, B3: 980, B4: 1550, B5: 1340, B6: 1105 },
|
||||
});
|
||||
|
||||
it('SUM / AVG / MIN / MAX / COUNT over a range', () => {
|
||||
expect(ev('=SUM(B2:B6)', host)).toBe(6175);
|
||||
expect(ev('=AVG(B2:B6)', host)).toBe(1235);
|
||||
expect(ev('=MIN(B2:B6)', host)).toBe(980);
|
||||
expect(ev('=MAX(B2:B6)', host)).toBe(1550);
|
||||
expect(ev('=COUNT(B2:B6)', host)).toBe(5);
|
||||
});
|
||||
|
||||
it('AVERAGE is an alias of AVG', () => {
|
||||
expect(ev('=AVERAGE(B2:B6)', host)).toBe(1235);
|
||||
});
|
||||
|
||||
it('mixes ranges, literals and single cells', () => {
|
||||
expect(ev('=SUM(B2:B3; 100; B4)', host)).toBe(1200 + 980 + 100 + 1550);
|
||||
});
|
||||
|
||||
it('COUNT ignores empty cells', () => {
|
||||
expect(ev('=COUNT(B2:B10)', host)).toBe(5);
|
||||
});
|
||||
|
||||
it('AVG of no numbers -> #DIV/0!', () => {
|
||||
const r = ev('=AVG(Z1:Z5)', host);
|
||||
expect(isError(r) && r.code).toBe('#DIV/0!');
|
||||
});
|
||||
|
||||
it('unknown function -> #NAME?', () => {
|
||||
const r = ev('=FOO(1)', host);
|
||||
expect(isError(r) && r.code).toBe('#NAME?');
|
||||
});
|
||||
|
||||
it('an omitted argument slot (e.g. SUM(1;;3)) behaves like an empty cell', () => {
|
||||
expect(ev('=SUM(1;;3)', host)).toBe(4);
|
||||
expect(ev('=COUNT(1;;3)', host)).toBe(2); // omitted, like an empty cell, isn't counted
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator IF', () => {
|
||||
const host = makeHost({ S1: { A1: 8, B1: 0 } });
|
||||
|
||||
it('picks the then-branch when the condition is truthy', () => {
|
||||
expect(ev('=IF(1;2;3)', host)).toBe(2);
|
||||
expect(ev('=IF(A1>5;"groß";"klein")', host)).toBe('groß');
|
||||
});
|
||||
|
||||
it('picks the else-branch when the condition is falsy', () => {
|
||||
expect(ev('=IF(0;2;3)', host)).toBe(3);
|
||||
expect(ev('=IF(A1<5;"groß";"klein")', host)).toBe('klein');
|
||||
});
|
||||
|
||||
it('returns 0 for a falsy condition with no else-branch', () => {
|
||||
expect(ev('=IF(0;2)', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('never evaluates the untaken branch (short-circuit)', () => {
|
||||
expect(ev('=IF(1;2;1/0)', host)).toBe(2);
|
||||
expect(ev('=IF(B1=0;0;A1/B1)', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('propagates an error from the condition itself', () => {
|
||||
const r = ev('=IF(1/0;2;3)', host);
|
||||
expect(isError(r) && r.code).toBe('#DIV/0!');
|
||||
});
|
||||
|
||||
it('wrong number of arguments -> #ERR', () => {
|
||||
expect(isError(ev('=IF(1)', host))).toBe(true);
|
||||
expect(isError(ev('=IF(1;2;3;4)', host))).toBe(true);
|
||||
});
|
||||
|
||||
it('works with comparison operators as the condition', () => {
|
||||
expect(ev('=IF(A1>=8;1;0)', host)).toBe(1);
|
||||
expect(ev('=IF(A1<>8;1;0)', host)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator AND/OR/NOT', () => {
|
||||
const host = makeHost({ S1: { A1: 8, B1: 0, C1: 'x' } });
|
||||
|
||||
it('AND is true only when every argument is truthy', () => {
|
||||
expect(ev('=AND(1;1;1)', host)).toBe(1);
|
||||
expect(ev('=AND(1;0;1)', host)).toBe(0);
|
||||
expect(ev('=AND(A1>0;A1<10)', host)).toBe(1);
|
||||
expect(ev('=AND(A1>0;A1<5)', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('OR is true when at least one argument is truthy', () => {
|
||||
expect(ev('=OR(0;0;1)', host)).toBe(1);
|
||||
expect(ev('=OR(0;0;0)', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('NOT inverts a single condition', () => {
|
||||
expect(ev('=NOT(1)', host)).toBe(0);
|
||||
expect(ev('=NOT(0)', host)).toBe(1);
|
||||
expect(ev('=NOT(B1=0)', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('a range argument checks every cell (Excel behaviour)', () => {
|
||||
const h = makeHost({ S1: { A1: 1, A2: 1, A3: 0 } });
|
||||
expect(ev('=AND(A1:A3)', h)).toBe(0);
|
||||
expect(ev('=OR(A1:A3)', h)).toBe(1);
|
||||
});
|
||||
|
||||
it('empty cell and non-empty text follow the shared truthy convention', () => {
|
||||
expect(ev('=AND(Z9)', host)).toBe(0); // empty cell -> falsy
|
||||
expect(ev('=AND(C1)', host)).toBe(1); // non-empty text -> truthy
|
||||
});
|
||||
|
||||
it('composes inside IF, unlike a bare chained comparison', () => {
|
||||
expect(ev('=IF(AND(A1>0;A1<10);"ok";"nein")', host)).toBe('ok');
|
||||
});
|
||||
|
||||
it('does NOT short-circuit -- every argument is evaluated, same as real Excel', () => {
|
||||
expect(isError(ev('=AND(1=2;1/0)', host))).toBe(true);
|
||||
expect(isError(ev('=OR(1=1;1/0)', host))).toBe(true);
|
||||
});
|
||||
|
||||
it('wrong arity -> #ERR', () => {
|
||||
expect(isError(ev('=AND()', host))).toBe(true);
|
||||
expect(isError(ev('=NOT(1;2)', host))).toBe(true);
|
||||
expect(isError(ev('=NOT()', host))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator error literals (ErrorLiteralNode)', () => {
|
||||
const host = makeHost({ S1: {} });
|
||||
|
||||
it('evaluates directly to that FormulaError', () => {
|
||||
const r = ev('=#REF', host);
|
||||
expect(isError(r) && r.code).toBe('#REF');
|
||||
});
|
||||
|
||||
it('propagates through arithmetic like any other error', () => {
|
||||
const r = ev('=1+#DIV/0!', host);
|
||||
expect(isError(r) && r.code).toBe('#DIV/0!');
|
||||
});
|
||||
|
||||
it('usable as an IF branch, e.g. a deliberate #N/A', () => {
|
||||
expect(ev('=IF(1=0;#N/A;5)', host)).toBe(5);
|
||||
const r = ev('=IF(1=1;#N/A;5)', host);
|
||||
expect(isError(r) && r.code).toBe('#N/A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator comparisons', () => {
|
||||
const host = makeHost({ S1: { A1: 'Nord', B1: 5 } });
|
||||
|
||||
it('numeric comparisons work as before, empty cell counts as 0', () => {
|
||||
expect(ev('=B1>=5', host)).toBe(1);
|
||||
expect(ev('=B1<>5', host)).toBe(0);
|
||||
expect(ev('=Z9=0', host)).toBe(1);
|
||||
});
|
||||
|
||||
it('text equality is case-insensitive and trimmed, like XLOOKUP', () => {
|
||||
expect(ev('=A1="Nord"', host)).toBe(1);
|
||||
expect(ev('=A1=" nord "', host)).toBe(1);
|
||||
expect(ev('=A1="Süd"', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('text ordering compares lexicographically', () => {
|
||||
expect(ev('=A1<"Süd"', host)).toBe(1);
|
||||
expect(ev('=A1>"Süd"', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('an empty cell takes on whichever type the other side is', () => {
|
||||
expect(ev('=Z9=""', host)).toBe(1);
|
||||
expect(ev('=Z9="x"', host)).toBe(0);
|
||||
});
|
||||
|
||||
it('a real number against real text never crossmatches: = / <> answer definitively, ordering is #VALUE!', () => {
|
||||
expect(ev('=B1="5"', host)).toBe(0);
|
||||
expect(ev('=B1<>"5"', host)).toBe(1);
|
||||
expect(isError(ev('=B1>"5"', host))).toBe(true);
|
||||
expect(isError(ev('=A1<5', host))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator & (text concatenation)', () => {
|
||||
const host = makeHost({ S1: { A1: 'Herr', B1: 5 } });
|
||||
|
||||
it('joins two strings', () => {
|
||||
expect(ev('="a"&"b"', host)).toBe('ab');
|
||||
});
|
||||
|
||||
it('renders a number plain, no locale formatting', () => {
|
||||
expect(ev('="Wert: "&B1', host)).toBe('Wert: 5');
|
||||
});
|
||||
|
||||
it('an empty cell contributes "" (not "0")', () => {
|
||||
expect(ev('="["&Z9&"]"', host)).toBe('[]');
|
||||
});
|
||||
|
||||
it('is left-associative and chains', () => {
|
||||
expect(ev('=A1&" "&"Wild"', host)).toBe('Herr Wild');
|
||||
});
|
||||
|
||||
it('binds looser than + but tighter than a comparison', () => {
|
||||
expect(ev('="a"&1+2', host)).toBe('a3');
|
||||
expect(ev('=A1&"x"="Herrx"', host)).toBe(1);
|
||||
});
|
||||
|
||||
it('propagates an error from either side', () => {
|
||||
expect(isError(ev('=(1/0)&"x"', host))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator CONCAT/CONCATENATE', () => {
|
||||
const host = makeHost({ S1: { A1: 'x', B1: 'y' } });
|
||||
|
||||
it('joins any number of arguments, no separator', () => {
|
||||
expect(ev('=CONCAT("a";"b";"c")', host)).toBe('abc');
|
||||
expect(ev('=CONCATENATE(A1;" ";B1)', host)).toBe('x y');
|
||||
});
|
||||
|
||||
it('an empty cell contributes ""', () => {
|
||||
expect(ev('=CONCAT(A1;Z9;B1)', host)).toBe('xy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator named ranges (NamedRefNode)', () => {
|
||||
it('a single-cell named range behaves exactly like that cell in scalar position', () => {
|
||||
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
|
||||
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
|
||||
});
|
||||
expect(ev('=Preis + 1', host)).toBe(43);
|
||||
});
|
||||
|
||||
it('matching is case-insensitive and trimmed, like a table qualifier', () => {
|
||||
const host = makeHost({ S1: { B2: 42 } }, 'S1', {
|
||||
preis: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } },
|
||||
});
|
||||
expect(ev('=PREIS', host)).toBe(42);
|
||||
});
|
||||
|
||||
it('a multi-cell named range works as a function argument (range position), same as SUM(B2:B3)', () => {
|
||||
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
|
||||
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
|
||||
});
|
||||
expect(ev('=SUM(Bereich)', host)).toBe(15);
|
||||
});
|
||||
|
||||
it('a multi-cell named range in scalar position is #VALUE!, same as a bare range', () => {
|
||||
const host = makeHost({ S1: { B2: 10, B3: 5 } }, 'S1', {
|
||||
bereich: { qualifier: 'S1', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } },
|
||||
});
|
||||
expect(isError(ev('=Bereich', host))).toBe(true);
|
||||
});
|
||||
|
||||
it('an unknown name is #NAME?', () => {
|
||||
expect(isError(ev('=DoesNotExist', makeHost({})))).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves through another table via its qualifier', () => {
|
||||
const host = makeHost({ S2: { C3: 7 } }, 'S1', {
|
||||
andernorts: { qualifier: 'S2', start: { col: 2, row: 2 }, end: { col: 2, row: 2 } },
|
||||
});
|
||||
expect(ev('=Andernorts * 2', host)).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator XLOOKUP', () => {
|
||||
const textHost = makeHost({
|
||||
S1: {
|
||||
A2: 'Apfel', B2: 1.5,
|
||||
A3: 'Birne', B3: 2,
|
||||
A4: 'Kirsche', B4: 5,
|
||||
A5: 'Birne', B5: 99, // duplicate, for search-mode tests
|
||||
},
|
||||
});
|
||||
const bracketHost = makeHost({
|
||||
S1: { D2: 0, E2: 'A', D3: 10, E3: 'B', D4: 20, E4: 'C', D5: 30, E5: 'D' },
|
||||
});
|
||||
|
||||
it('exact-matches text, case-insensitively and trimmed', () => {
|
||||
expect(ev('=XLOOKUP("Birne";A2:A4;B2:B4)', textHost)).toBe(2);
|
||||
expect(ev('=XLOOKUP(" BIRNE ";A2:A4;B2:B4)', textHost)).toBe(2);
|
||||
});
|
||||
|
||||
it('exact-matches numbers', () => {
|
||||
expect(ev('=XLOOKUP(10;D2:D5;E2:E5)', bracketHost)).toBe('B');
|
||||
});
|
||||
|
||||
it('does not cross-match a number against text', () => {
|
||||
const r = ev('=XLOOKUP(5;A2:A4;B2:B4)', textHost);
|
||||
expect(isError(r) && r.code).toBe('#N/A');
|
||||
});
|
||||
|
||||
it('no match and no if_not_found -> #N/A', () => {
|
||||
const r = ev('=XLOOKUP("Traube";A2:A4;B2:B4)', textHost);
|
||||
expect(isError(r) && r.code).toBe('#N/A');
|
||||
});
|
||||
|
||||
it('no match falls back to if_not_found', () => {
|
||||
expect(ev('=XLOOKUP("Traube";A2:A4;B2:B4;"?")', textHost)).toBe('?');
|
||||
});
|
||||
|
||||
it('if_not_found is evaluated lazily (never touched on a match)', () => {
|
||||
expect(ev('=XLOOKUP("Birne";A2:A4;B2:B4;1/0)', textHost)).toBe(2);
|
||||
});
|
||||
|
||||
it('search_mode: 1 (default) finds the first match, -1 the last', () => {
|
||||
expect(ev('=XLOOKUP("Birne";A2:A5;B2:B5)', textHost)).toBe(2);
|
||||
expect(ev('=XLOOKUP("Birne";A2:A5;B2:B5;;0;-1)', textHost)).toBe(99);
|
||||
});
|
||||
|
||||
it('match_mode -1/1: exact match still wins outright', () => {
|
||||
expect(ev('=XLOOKUP(10;D2:D5;E2:E5;;-1)', bracketHost)).toBe('B');
|
||||
expect(ev('=XLOOKUP(10;D2:D5;E2:E5;;1)', bracketHost)).toBe('B');
|
||||
});
|
||||
|
||||
it('match_mode -1: nearest smaller', () => {
|
||||
expect(ev('=XLOOKUP(15;D2:D5;E2:E5;;-1)', bracketHost)).toBe('B'); // 10
|
||||
});
|
||||
|
||||
it('match_mode 1: nearest larger', () => {
|
||||
expect(ev('=XLOOKUP(15;D2:D5;E2:E5;;1)', bracketHost)).toBe('C'); // 20
|
||||
});
|
||||
|
||||
it('match_mode -1/1: no qualifying candidate -> #N/A (or if_not_found)', () => {
|
||||
expect(isError(ev('=XLOOKUP(-5;D2:D5;E2:E5;;-1)', bracketHost))).toBe(true);
|
||||
expect(ev('=XLOOKUP(-5;D2:D5;E2:E5;"keine";-1)', bracketHost)).toBe('keine');
|
||||
});
|
||||
|
||||
it('unsupported match_mode (2) or search_mode (2/-2) -> #ERR', () => {
|
||||
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;;2)', textHost))).toBe(true);
|
||||
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;;0;2)', textHost))).toBe(true);
|
||||
});
|
||||
|
||||
it('mismatched lookup/return range sizes -> #VALUE!', () => {
|
||||
const r = ev('=XLOOKUP("x";A2:A4;B2:B5)', textHost);
|
||||
expect(isError(r) && r.code).toBe('#VALUE!');
|
||||
});
|
||||
|
||||
it('wrong number of arguments -> #ERR', () => {
|
||||
expect(isError(ev('=XLOOKUP("x";A2:A4)', textHost))).toBe(true);
|
||||
expect(isError(ev('=XLOOKUP("x";A2:A4;B2:B4;1;2;3;4)', textHost))).toBe(true);
|
||||
});
|
||||
|
||||
it('works with single cells (a 1-cell "range") for lookup/return', () => {
|
||||
expect(ev('=XLOOKUP("Apfel";A2;B2)', textHost)).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluator cross-sheet & errors', () => {
|
||||
const host = makeHost({
|
||||
S1: { A1: 3 },
|
||||
S2: { A1: 7, B1: 100 },
|
||||
});
|
||||
|
||||
it('resolves Name!Ref and is case-insensitive', () => {
|
||||
expect(ev('=S2!A1 + S2!B1', host)).toBe(107);
|
||||
expect(ev('=s2!a1', host)).toBe(7);
|
||||
});
|
||||
|
||||
it('accepts "::" the same as "!"', () => {
|
||||
expect(ev('=S2::A1 + S2::B1', host)).toBe(107);
|
||||
expect(ev('=s2::a1', host)).toBe(ev('=S2!A1', host));
|
||||
});
|
||||
|
||||
it('unknown sheet -> #REF', () => {
|
||||
const r = ev('=Nix!A1', host);
|
||||
expect(isError(r) && r.code).toBe('#REF');
|
||||
});
|
||||
|
||||
it('propagates an error from a referenced cell', () => {
|
||||
const h = makeHost({ S1: { A1: new FormulaError('#DIV/0!') } });
|
||||
const r = ev('=A1 + 1', h);
|
||||
expect(isError(r) && r.code).toBe('#DIV/0!');
|
||||
const r2 = ev('=SUM(A1; 5)', h);
|
||||
expect(isError(r2) && r2.code).toBe('#DIV/0!');
|
||||
});
|
||||
|
||||
it('a bare range in scalar position -> #VALUE!', () => {
|
||||
const r = ev('=A1:A3 + 1', host);
|
||||
expect(isError(r) && r.code).toBe('#VALUE!');
|
||||
});
|
||||
|
||||
it('text in arithmetic -> #VALUE!, numeric text is coerced', () => {
|
||||
const h = makeHost({ S1: { A1: 'abc', A2: '12' } });
|
||||
expect(isError(ev('=A1*2', h))).toBe(true);
|
||||
expect(ev('=A2*2', h)).toBe(24);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FUNCTIONS, BUILTIN_SIGNATURES } from '../functions';
|
||||
|
||||
describe('BUILTIN_SIGNATURES', () => {
|
||||
it('has exactly one entry per registered built-in function, names matching', () => {
|
||||
const registered = Object.keys(FUNCTIONS).sort();
|
||||
const signatures = BUILTIN_SIGNATURES.map((s) => s.name).sort();
|
||||
// A future new built-in without a matching hint entry fails loudly here
|
||||
// instead of just silently being missing from the autocomplete dropdown.
|
||||
expect(signatures).toEqual(registered);
|
||||
});
|
||||
|
||||
it('every hint actually starts with its own name', () => {
|
||||
for (const s of BUILTIN_SIGNATURES) {
|
||||
expect(s.hint.startsWith(s.name)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DependencyGraph } from '../graph';
|
||||
|
||||
/**
|
||||
* Helper: build a graph from "cell <- precedent, precedent" lines.
|
||||
* Node names are arbitrary strings here (real code uses `keyOf`).
|
||||
*/
|
||||
function build(edges: Record<string, string[]>): DependencyGraph {
|
||||
const g = new DependencyGraph();
|
||||
for (const [cell, precs] of Object.entries(edges)) g.setPrecedents(cell, precs);
|
||||
return g;
|
||||
}
|
||||
|
||||
describe('DependencyGraph.affected', () => {
|
||||
it('returns dirty cells plus their transitive dependents', () => {
|
||||
// A -> B -> C ; A -> D
|
||||
const g = build({ B: ['A'], C: ['B'], D: ['A'] });
|
||||
expect([...g.affected(['A'])].sort()).toEqual(['A', 'B', 'C', 'D']);
|
||||
expect([...g.affected(['B'])].sort()).toEqual(['B', 'C']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DependencyGraph.topoOrder', () => {
|
||||
it('orders precedents before dependents', () => {
|
||||
const g = build({ B: ['A'], C: ['A', 'B'], D: ['C'] });
|
||||
const { order, circular } = g.topoOrder(g.affected(['A']));
|
||||
expect(circular.size).toBe(0);
|
||||
const pos = (n: string) => order.indexOf(n);
|
||||
expect(pos('A')).toBeLessThan(pos('B'));
|
||||
expect(pos('B')).toBeLessThan(pos('C'));
|
||||
expect(pos('C')).toBeLessThan(pos('D'));
|
||||
});
|
||||
|
||||
it('detects a direct self-reference', () => {
|
||||
const g = build({ A: ['A'] });
|
||||
const { circular } = g.topoOrder(g.affected(['A']));
|
||||
expect([...circular]).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('detects a 2-cycle and flags both nodes', () => {
|
||||
const g = build({ A: ['B'], B: ['A'] });
|
||||
const { order, circular } = g.topoOrder(g.affected(['A']));
|
||||
expect([...circular].sort()).toEqual(['A', 'B']);
|
||||
expect(order).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects a longer cycle, leaves acyclic tail evaluable', () => {
|
||||
// cycle A->B->C->A ; plus D reads C (D is not on the cycle)
|
||||
const g = build({ A: ['C'], B: ['A'], C: ['B'], D: ['C'] });
|
||||
const { order, circular } = g.topoOrder(g.affected(['A']));
|
||||
expect([...circular].sort()).toEqual(['A', 'B', 'C']);
|
||||
expect(order).toEqual(['D']);
|
||||
});
|
||||
|
||||
it('rewiring precedents removes stale edges', () => {
|
||||
const g = build({ B: ['A'] });
|
||||
expect([...g.affected(['A'])].sort()).toEqual(['A', 'B']);
|
||||
g.setPrecedents('B', []); // B no longer reads A
|
||||
expect([...g.affected(['A'])].sort()).toEqual(['A']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tokenize } from '../lexer';
|
||||
|
||||
const types = (s: string) => tokenize(s).map((t) => t.type);
|
||||
const values = (s: string) => tokenize(s).map((t) => t.value);
|
||||
|
||||
describe('lexer', () => {
|
||||
it('tokenizes numbers incl. decimals and exponents', () => {
|
||||
expect(values('1 2.5 .5 1e3 1.2E-3')).toEqual([
|
||||
'1',
|
||||
'2.5',
|
||||
'.5',
|
||||
'1e3',
|
||||
'1.2E-3',
|
||||
'',
|
||||
]);
|
||||
expect(types('42')).toEqual(['NUMBER', 'EOF']);
|
||||
});
|
||||
|
||||
it('tokenizes operators and punctuation', () => {
|
||||
expect(types('+-*/^&():;!')).toEqual([
|
||||
'PLUS',
|
||||
'MINUS',
|
||||
'STAR',
|
||||
'SLASH',
|
||||
'CARET',
|
||||
'AMP',
|
||||
'LPAREN',
|
||||
'RPAREN',
|
||||
'COLON',
|
||||
'SEMICOLON',
|
||||
'BANG',
|
||||
'EOF',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads bare words as IDENT (cell refs, functions, sheet names alike)', () => {
|
||||
expect(types('SUM(A1:B2)')).toEqual([
|
||||
'IDENT',
|
||||
'LPAREN',
|
||||
'IDENT',
|
||||
'COLON',
|
||||
'IDENT',
|
||||
'RPAREN',
|
||||
'EOF',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads single-quoted sheet names with spaces', () => {
|
||||
const t = tokenize("'Kosten Q3'!B2");
|
||||
expect(t[0]).toMatchObject({ type: 'QUOTED', value: 'Kosten Q3' });
|
||||
expect(t[1].type).toBe('BANG');
|
||||
expect(t[2]).toMatchObject({ type: 'IDENT', value: 'B2' });
|
||||
});
|
||||
|
||||
it('handles escaped double-quoted strings', () => {
|
||||
expect(tokenize('"a\\"b"')[0]).toMatchObject({ type: 'STRING', value: 'a"b' });
|
||||
});
|
||||
|
||||
it('reads $-locked cell references as one IDENT', () => {
|
||||
expect(types('$A$1 A$1 $A1')).toEqual(['IDENT', 'IDENT', 'IDENT', 'EOF']);
|
||||
expect(values('$A$1 A$1 $A1')).toEqual(['$A$1', 'A$1', '$A1', '']);
|
||||
});
|
||||
|
||||
it('reads identifiers with non-ASCII letters (umlauts etc.) as a single IDENT', () => {
|
||||
// a table/sheet name routinely contains "ä"/"ö"/"ü"/"ß" in this
|
||||
// German-first app -- these must lex as ordinary IDENT characters, not
|
||||
// throw, so an unquoted qualifier like "Übersicht::A1" actually works.
|
||||
expect(types('Übersicht::B4')).toEqual(['IDENT', 'DBLCOLON', 'IDENT', 'EOF']);
|
||||
expect(values('Übersicht')).toEqual(['Übersicht', '']);
|
||||
expect(values('Größe_Prämie')).toEqual(['Größe_Prämie', '']);
|
||||
});
|
||||
|
||||
it('skips whitespace', () => {
|
||||
expect(types(' A1\t+\n B2 ')).toEqual(['IDENT', 'PLUS', 'IDENT', 'EOF']);
|
||||
});
|
||||
|
||||
it('throws on an unexpected character', () => {
|
||||
expect(() => tokenize('A1 @ B2')).toThrow();
|
||||
});
|
||||
|
||||
it('tokenizes assignment/equality and comparison operators', () => {
|
||||
expect(types('= < > <= >= <>')).toEqual([
|
||||
'EQUALS',
|
||||
'LT',
|
||||
'GT',
|
||||
'LTE',
|
||||
'GTE',
|
||||
'NEQ',
|
||||
'EOF',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers the two-character comparison operators over their single-character prefix', () => {
|
||||
expect(types('A1<=B2')).toEqual(['IDENT', 'LTE', 'IDENT', 'EOF']);
|
||||
expect(types('A1>=B2')).toEqual(['IDENT', 'GTE', 'IDENT', 'EOF']);
|
||||
expect(types('A1<>B2')).toEqual(['IDENT', 'NEQ', 'IDENT', 'EOF']);
|
||||
expect(types('A1<B2')).toEqual(['IDENT', 'LT', 'IDENT', 'EOF']);
|
||||
expect(types('A1>B2')).toEqual(['IDENT', 'GT', 'IDENT', 'EOF']);
|
||||
});
|
||||
|
||||
it('tokenizes every error code as one ERRORLIT token', () => {
|
||||
expect(types('#REF')).toEqual(['ERRORLIT', 'EOF']);
|
||||
expect(values('#REF')).toEqual(['#REF', '']);
|
||||
expect(types('#DIV/0!')).toEqual(['ERRORLIT', 'EOF']);
|
||||
expect(types('#NAME?')).toEqual(['ERRORLIT', 'EOF']);
|
||||
expect(types('#ERR')).toEqual(['ERRORLIT', 'EOF']);
|
||||
expect(types('#CIRC')).toEqual(['ERRORLIT', 'EOF']);
|
||||
expect(types('#VALUE!')).toEqual(['ERRORLIT', 'EOF']);
|
||||
expect(types('#N/A')).toEqual(['ERRORLIT', 'EOF']);
|
||||
});
|
||||
|
||||
it('an ERRORLIT is followed by whatever comes next, not swallowed into it', () => {
|
||||
expect(types('1+#REF')).toEqual(['NUMBER', 'PLUS', 'ERRORLIT', 'EOF']);
|
||||
// "#REF" matches greedily -- a trailing "!" some might expect (Excel
|
||||
// spells it "#REF!") is its own BANG token, not part of the literal;
|
||||
// this engine's own error code is "#REF" without "!" (see types.ts).
|
||||
expect(types('#REF!')).toEqual(['ERRORLIT', 'BANG', 'EOF']);
|
||||
});
|
||||
|
||||
it('throws on an unrecognized "#..." sequence', () => {
|
||||
expect(() => tokenize('#FOO')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMacroBlock } from '../parser';
|
||||
import { runMacroActions } from '../evaluator';
|
||||
import { buildFunctionIndex, expandMacroBlock, type FunctionDef } from '../userFunctions';
|
||||
import {
|
||||
FormulaError,
|
||||
type CellRef,
|
||||
type CellValue,
|
||||
type EngineHost,
|
||||
type MacroNetworkHost,
|
||||
type MacroWriteHost,
|
||||
} from '../types';
|
||||
|
||||
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
|
||||
|
||||
/** Same shape as evaluator.spec.ts's makeHost, but resolves any of a small
|
||||
* fixed set of table names ("Umsatz"/"Kosten" -- matching this file's test
|
||||
* formulas, chosen to read naturally rather than the generic "S1"/"S2")
|
||||
* case-insensitively to themselves, since a macro test cares about which
|
||||
* name got resolved, not an opaque id. */
|
||||
function makeHost(
|
||||
data: Record<string, Record<string, CellValue>>,
|
||||
home = 'Umsatz',
|
||||
namedRanges: Record<string, NamedRangeShape> = {},
|
||||
): EngineHost {
|
||||
const known = ['Umsatz', 'Kosten'];
|
||||
const idFor = (name: string): string | null => known.find((t) => t.toLowerCase() === name.trim().toLowerCase()) ?? null;
|
||||
return {
|
||||
homeTableId: home,
|
||||
resolveTableName: idFor,
|
||||
resolveNamedRange: (name) => namedRanges[name.trim().toLowerCase()] ?? null,
|
||||
tableSize: () => ({ cols: 10, rows: 2000 }),
|
||||
getComputed(tableId, col, row) {
|
||||
const cell = String.fromCharCode(65 + col) + (row + 1);
|
||||
const v = data[tableId]?.[cell];
|
||||
return v === undefined ? null : v;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type RecordedCall = { op: string; args: unknown[] };
|
||||
|
||||
/** Records every call instead of mutating anything -- these tests are about
|
||||
* the interpreter dispatching the right action with the right resolved
|
||||
* arguments, not about a real document (that's store/document.spec.ts's
|
||||
* job, against the real store primitives). */
|
||||
function makeWriteHost(): { host: MacroWriteHost; calls: RecordedCall[] } {
|
||||
const calls: RecordedCall[] = [];
|
||||
const record =
|
||||
(op: string) =>
|
||||
(...args: unknown[]): void => {
|
||||
calls.push({ op, args });
|
||||
};
|
||||
const host: MacroWriteHost = {
|
||||
setValue: record('setValue'),
|
||||
insertRow: record('insertRow'),
|
||||
deleteRow: record('deleteRow'),
|
||||
insertCol: record('insertCol'),
|
||||
deleteCol: record('deleteCol'),
|
||||
addTable: record('addTable'),
|
||||
renameTable: record('renameTable'),
|
||||
deleteTable: record('deleteTable'),
|
||||
addSheet: record('addSheet'),
|
||||
renameSheet: record('renameSheet'),
|
||||
deleteSheet: record('deleteSheet'),
|
||||
};
|
||||
return { host, calls };
|
||||
}
|
||||
|
||||
/** A mock `MacroNetworkHost` -- `responses` maps "METHOD url" to either a
|
||||
* value to resolve with (wrapped as `{ok:true, value}`) or an Error to
|
||||
* reject with (`{ok:false, error}`); an un-mapped request rejects with a
|
||||
* loud "no mock for this request" error so a test that forgets to mock a
|
||||
* call fails clearly instead of hanging. */
|
||||
function makeNetHost(
|
||||
responses: Record<string, { ok: true; value: unknown } | { ok: false; error: Error }>,
|
||||
): { host: MacroNetworkHost; calls: { method: string; url: string; body?: string }[] } {
|
||||
const calls: { method: string; url: string; body?: string }[] = [];
|
||||
const host: MacroNetworkHost = {
|
||||
async request(method, url, body) {
|
||||
calls.push({ method, url, body });
|
||||
const key = `${method} ${url}`;
|
||||
const mocked = responses[key];
|
||||
if (!mocked) throw new Error(`no mock for ${key}`);
|
||||
if (!mocked.ok) throw mocked.error;
|
||||
return mocked.value;
|
||||
},
|
||||
};
|
||||
return { host, calls };
|
||||
}
|
||||
|
||||
const emptyNetHost: MacroNetworkHost = { request: () => Promise.reject(new Error('no network calls expected')) };
|
||||
|
||||
const runMacro = (
|
||||
src: string,
|
||||
host: EngineHost,
|
||||
writeHost: MacroWriteHost,
|
||||
netHost: MacroNetworkHost = emptyNetHost,
|
||||
) => runMacroActions(parseMacroBlock(src), host, writeHost, netHost);
|
||||
|
||||
describe('runMacroActions -- SETVALUE target resolution', () => {
|
||||
it('a literal cell target writes exactly one cell', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
await runMacro('SETVALUE(Umsatz::B2; 42)', makeHost({}), writeHost);
|
||||
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 1, row: 1 }], '42'] }]);
|
||||
});
|
||||
|
||||
it('a range target broadcasts the same value to every cell in one call', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
await runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost);
|
||||
expect(calls).toHaveLength(1);
|
||||
const [cells, text] = calls[0].args as [CellRef[], string];
|
||||
expect(cells).toEqual([
|
||||
{ tableId: 'Umsatz', col: 0, row: 0 },
|
||||
{ tableId: 'Umsatz', col: 0, row: 1 },
|
||||
{ tableId: 'Umsatz', col: 0, row: 2 },
|
||||
]);
|
||||
expect(text).toBe('x');
|
||||
});
|
||||
|
||||
it('a target resolved through a FOR loop variable (substitute() through env)', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
await runMacro('FOR c IN Umsatz::A1:A2 DO SETVALUE(c; 0) END', makeHost({}), writeHost);
|
||||
expect(calls).toEqual([
|
||||
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '0'] },
|
||||
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 1 }], '0'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a value that starts with "=" is stringified as-is -- the store applies it as a live formula, no special-casing needed here', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
await runMacro('SETVALUE(Umsatz::A1; "=SUM(" & "A2:A3" & ")")', makeHost({}), writeHost);
|
||||
expect(calls[0].args[1]).toBe('=SUM(A2:A3)');
|
||||
});
|
||||
|
||||
it('rejects an unqualified target at runtime (the static save-time check in document.ts is the primary guard; this is the interpreter\'s own defensive backstop)', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
await expect(runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Tabelle nennen/);
|
||||
});
|
||||
|
||||
it('rejects a computed (non-reference) target', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
await expect(runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Zell- oder Bereichsbezug/);
|
||||
});
|
||||
|
||||
it('rejects an unknown table name', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
await expect(runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/unbekannte Tabelle/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMacroActions -- RETURN / fall-through', () => {
|
||||
it('RETURN short-circuits and its value is returned', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
await expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).resolves.toBe('fertig');
|
||||
});
|
||||
|
||||
it('a RETURN inside an IF stops the remaining statements from running', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
await runMacro('IF 1 = 1 THEN RETURN "abgebrochen" END SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('falling off the end without RETURN yields null, not an error (unlike an ordinary block-kind custom function)', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
await expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMacroActions -- the full action vocabulary dispatches correctly', () => {
|
||||
it('every non-SETVALUE action evaluates its (string/number) args and calls the matching writeHost method', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
await runMacro(
|
||||
[
|
||||
'INSERTROW("Umsatz"; 3)',
|
||||
'DELETEROW("Umsatz"; 4)',
|
||||
'INSERTCOL("Umsatz"; 1)',
|
||||
'DELETECOL("Umsatz"; 2)',
|
||||
'ADDTABLE("Blatt1"; "Neu")',
|
||||
'RENAMETABLE("Umsatz"; "Verkauf")',
|
||||
'DELETETABLE("Alt")',
|
||||
'ADDSHEET("Blatt2")',
|
||||
'RENAMESHEET("Blatt1"; "Analyse")',
|
||||
'DELETESHEET("Blatt3")',
|
||||
].join(' '),
|
||||
makeHost({}),
|
||||
writeHost,
|
||||
);
|
||||
expect(calls).toEqual([
|
||||
{ op: 'insertRow', args: ['Umsatz', 3] },
|
||||
{ op: 'deleteRow', args: ['Umsatz', 4] },
|
||||
{ op: 'insertCol', args: ['Umsatz', 1] },
|
||||
{ op: 'deleteCol', args: ['Umsatz', 2] },
|
||||
{ op: 'addTable', args: ['Blatt1', 'Neu'] },
|
||||
{ op: 'renameTable', args: ['Umsatz', 'Verkauf'] },
|
||||
{ op: 'deleteTable', args: ['Alt'] },
|
||||
{ op: 'addSheet', args: ['Blatt2'] },
|
||||
{ op: 'renameSheet', args: ['Blatt1', 'Analyse'] },
|
||||
{ op: 'deleteSheet', args: ['Blatt3'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a string argument built via & concatenation and a local variable both work, since every non-target argument goes through the ordinary expression pipeline', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
await runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost);
|
||||
expect(calls).toEqual([{ op: 'addTable', args: ['Blatt1', 'Neu Kopie'] }]);
|
||||
});
|
||||
|
||||
it('an unknown action name throws', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
await expect(runMacro('FOOBAR(1)', makeHost({}), writeHost)).rejects.toThrow(/Unbekannte Aktion/);
|
||||
});
|
||||
|
||||
it('a wrong argument count throws', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
await expect(runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).rejects.toThrow(/erwartet 1 Argument/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMacroActions -- macro-specific step budget', () => {
|
||||
it('a loop that executes many structural actions is stopped by the (smaller) macro budget, not the ordinary 10,000-step FOR budget', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
// 2,000 iterations, each one action call -- exceeds MAX_MACRO_STEPS
|
||||
// (1,000) but would be nowhere near the ordinary MAX_LOOP_STEPS
|
||||
// (10,000) an ordinary block-kind function loop gets.
|
||||
await expect(
|
||||
runMacro('FOR c IN Umsatz::A1:A2000 DO INSERTROW("Umsatz"; 1) END', makeHost({}), writeHost),
|
||||
).rejects.toThrow(/zu viele Schritte|zu viele Zellen/);
|
||||
});
|
||||
|
||||
it('sequential GET calls in a loop also count toward the budget', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 1 } });
|
||||
await expect(
|
||||
runMacro('FOR c IN Umsatz::A1:A2000 DO r = GET("https://x") END', makeHost({}), writeHost, netHost),
|
||||
).rejects.toThrow(/zu viele Schritte|zu viele Zellen/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMacroActions -- nested custom-function calls inside a macro expression', () => {
|
||||
it('a SETVALUE value can call a custom function, resolved via expandMacroBlock before running', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const defs: FunctionDef[] = [
|
||||
{ id: 'fn-1', name: 'MARGE', params: ['a', 'b'], body: 'a - b', kind: 'expression' },
|
||||
];
|
||||
const index = buildFunctionIndex(defs);
|
||||
const block = expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))'), index);
|
||||
await runMacroActions(block, makeHost({}), writeHost, emptyNetHost);
|
||||
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '7'] }]);
|
||||
});
|
||||
|
||||
it('an ActionNode can never appear inside a nested custom-function body -- parseFunctionBlock (always allowWrites:false) rejects write syntax even if a macro author tried to sneak one in', () => {
|
||||
expect(() => {
|
||||
const defs: FunctionDef[] = [
|
||||
{ id: 'fn-1', name: 'BAD', params: [], body: 'SETVALUE(A1;1) RETURN 1', kind: 'block' },
|
||||
];
|
||||
const index = buildFunctionIndex(defs);
|
||||
expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; BAD())'), index);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMacroActions -- error propagation', () => {
|
||||
it('an error value in a value expression aborts with a clear message, not a silently-written "#ERR" text', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
await expect(runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).rejects.toThrow(
|
||||
new FormulaError('#DIV/0!').code,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMacroActions -- GET/POST', () => {
|
||||
it('GET binds the parsed JSON response, usable as a subsequent SETVALUE value', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost, calls: netCalls } = makeNetHost({
|
||||
'GET https://api/kunde': { ok: true, value: { name: 'Nord' } },
|
||||
});
|
||||
await runMacro('r = GET("https://api/kunde") SETVALUE(Umsatz::A1; JSONGET(r; "name"))', makeHost({}), writeHost, netHost);
|
||||
expect(netCalls).toEqual([{ method: 'GET', url: 'https://api/kunde', body: undefined }]);
|
||||
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]);
|
||||
});
|
||||
|
||||
it('POST sends a body and binds the parsed JSON response', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost, calls: netCalls } = makeNetHost({
|
||||
'POST https://api/kunden': { ok: true, value: { id: 42 } },
|
||||
});
|
||||
await runMacro(
|
||||
'r = POST("https://api/kunden"; "{\\"name\\":\\"Nord\\"}") SETVALUE(Umsatz::A1; JSONGET(r; "id"))',
|
||||
makeHost({}),
|
||||
writeHost,
|
||||
netHost,
|
||||
);
|
||||
expect(netCalls).toEqual([{ method: 'POST', url: 'https://api/kunden', body: '{"name":"Nord"}' }]);
|
||||
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '42'] }]);
|
||||
});
|
||||
|
||||
it('GET/POST used anywhere other than the whole RHS of a plain assignment fall through to the ordinary path and become #NAME? -- they need a real await, so (unlike JSONGET below) they cannot be embedded in an arbitrary expression', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
// nested inside an expression -- SETVALUE's error wrapping only
|
||||
// surfaces the FormulaError *code*, not its detail text, so #NAME?
|
||||
// (not the literal words "unknown function") is what's actually visible
|
||||
await expect(
|
||||
runMacro('SETVALUE(Umsatz::A1; 1 + GET("https://x"))', makeHost({}), writeHost),
|
||||
).rejects.toThrow(/NAME/);
|
||||
// inside an IF condition -- an error in a condition resolves the whole
|
||||
// macro run with that FormulaError as its "value" (same as any other
|
||||
// ifstmt-condition error, existing behaviour predating this feature),
|
||||
// it does not reject the promise
|
||||
const result = await runMacro('IF GET("https://x") = 1 THEN SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost);
|
||||
expect(result).toEqual(new FormulaError('#NAME?', 'unknown function GET'));
|
||||
});
|
||||
|
||||
it('JSONGET, unlike GET/POST, is purely synchronous and works nested anywhere an expression can appear (evalScalar special-case, not just the assignment-RHS form)', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { name: 'Nord' } } });
|
||||
await runMacro(
|
||||
'r = GET("https://x") IF JSONGET(r; "name") = "Nord" THEN SETVALUE(Umsatz::A1; JSONGET(r; "name")) END',
|
||||
makeHost({}),
|
||||
writeHost,
|
||||
netHost,
|
||||
);
|
||||
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]);
|
||||
});
|
||||
|
||||
it('a network failure aborts the run cleanly with the failure message', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({
|
||||
'GET https://down': { ok: false, error: new Error('GET https://down: HTTP 500 Internal Server Error') },
|
||||
});
|
||||
await expect(runMacro('r = GET("https://down")', makeHost({}), writeHost, netHost)).rejects.toThrow(/HTTP 500/);
|
||||
});
|
||||
|
||||
it('a declined confirmation (surfaced by the host as a rejection) aborts without writing anything', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({
|
||||
'GET https://x': { ok: false, error: new Error("GET: Verbindung zu 'x' abgelehnt") },
|
||||
});
|
||||
await expect(
|
||||
runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost, netHost),
|
||||
).rejects.toThrow(/abgelehnt/);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMacroActions -- JSON value collapse rule (evalScalar\'s case "json")', () => {
|
||||
const cases: Array<[unknown, CellValue]> = [
|
||||
[null, null],
|
||||
['Nord', 'Nord'],
|
||||
[42, 42],
|
||||
[true, 1],
|
||||
[false, 0],
|
||||
[{ a: 1 }, '{"a":1}'],
|
||||
[[1, 2], '[1,2]'],
|
||||
];
|
||||
for (const [value, expected] of cases) {
|
||||
it(`a JSON ${JSON.stringify(value)} used directly as a SETVALUE value collapses to ${JSON.stringify(expected)}`, async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value } });
|
||||
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; r)', makeHost({}), writeHost, netHost);
|
||||
expect(calls[0].args[1]).toBe(String(expected ?? ''));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('runMacroActions -- JSONGET path resolution', () => {
|
||||
const data = { items: [{ name: 'Nord', tags: ['a', 'b'] }, { name: 'Süd' }], meta: { count: 2 } };
|
||||
|
||||
it('resolves a nested object path', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
|
||||
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "meta.count"))', makeHost({}), writeHost, netHost);
|
||||
expect(calls[0].args[1]).toBe('2');
|
||||
});
|
||||
|
||||
it('resolves an array index within the path', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
|
||||
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.name"))', makeHost({}), writeHost, netHost);
|
||||
expect(calls[0].args[1]).toBe('Nord');
|
||||
});
|
||||
|
||||
it('a nested array-of-strings path resolves too', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
|
||||
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.tags.1"))', makeHost({}), writeHost, netHost);
|
||||
expect(calls[0].args[1]).toBe('b');
|
||||
});
|
||||
|
||||
it('a missing path resolves to JSON null, not a throw', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
|
||||
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.9.name"))', makeHost({}), writeHost, netHost);
|
||||
expect(calls[0].args[1]).toBe(''); // null collapses to '' via toText(), same as an empty cell
|
||||
});
|
||||
|
||||
it('JSONGET on a non-JSON value throws a clear error when used as an assignment RHS', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
await expect(
|
||||
runMacro('x = 5 y = JSONGET(x; "a")', makeHost({}), writeHost),
|
||||
).rejects.toThrow(/JSON-Ergebnis/);
|
||||
});
|
||||
|
||||
it('JSONGET on a non-JSON value nested in an expression degrades to a formula error (#VALUE!), same as any other evalScalar function misuse', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
await expect(
|
||||
runMacro('x = 5 SETVALUE(Umsatz::A1; JSONGET(x; "a"))', makeHost({}), writeHost),
|
||||
).rejects.toThrow(/VALUE/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runMacroActions -- FOR over a JSON array', () => {
|
||||
it('iterates each element, usable via JSONGET', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({
|
||||
'GET https://x': { ok: true, value: [{ name: 'Nord' }, { name: 'Süd' }] },
|
||||
});
|
||||
await runMacro(
|
||||
'r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END',
|
||||
makeHost({}),
|
||||
writeHost,
|
||||
netHost,
|
||||
);
|
||||
expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']);
|
||||
});
|
||||
|
||||
it('FOR over a JSON object (not an array) throws a clear error', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { a: 1 } } });
|
||||
await expect(
|
||||
runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost),
|
||||
).rejects.toThrow(/Array/);
|
||||
});
|
||||
|
||||
it('FOR over a JSON primitive throws the same clear error', async () => {
|
||||
const { host: writeHost } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 5 } });
|
||||
await expect(
|
||||
runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost),
|
||||
).rejects.toThrow(/Array/);
|
||||
});
|
||||
|
||||
it('a JSONGET(...) call used directly as the FOR source (not first bound to a variable) parses and runs -- found live: the shared FOR-source shape check did not originally know about this new source kind', async () => {
|
||||
const { host: writeHost, calls } = makeWriteHost();
|
||||
const { host: netHost } = makeNetHost({
|
||||
'GET https://x': { ok: true, value: { items: [{ name: 'Nord' }, { name: 'Süd' }] } },
|
||||
});
|
||||
await runMacro(
|
||||
'r = GET("https://x") FOR item IN JSONGET(r; "items") DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END',
|
||||
makeHost({}),
|
||||
writeHost,
|
||||
netHost,
|
||||
);
|
||||
expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
parseMacroBlock,
|
||||
collectRefs,
|
||||
collectRefsInBlock,
|
||||
ParseError,
|
||||
type Node,
|
||||
type Statement,
|
||||
type BlockNode,
|
||||
} from '../parser';
|
||||
|
||||
/** Compact s-expression rendering of an AST for precedence assertions. */
|
||||
function sexpr(n: Node): string {
|
||||
switch (n.kind) {
|
||||
case 'number':
|
||||
return String(n.value);
|
||||
case 'string':
|
||||
return JSON.stringify(n.value);
|
||||
case 'errorliteral':
|
||||
return n.code;
|
||||
case 'unary':
|
||||
return `(${n.op} ${sexpr(n.operand)})`;
|
||||
case 'binary':
|
||||
return `(${n.op} ${sexpr(n.left)} ${sexpr(n.right)})`;
|
||||
case 'func':
|
||||
return `(${n.name} ${n.args.map(sexpr).join(' ')})`;
|
||||
case 'cell':
|
||||
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}cell(${n.col},${n.row})`;
|
||||
case 'range':
|
||||
return `${n.sheetQualifier ? n.sheetQualifier + '!' : ''}${n.qualifier ? n.qualifier + '!' : ''}range(${n.start.col},${n.start.row}:${n.end.col},${n.end.row})`;
|
||||
case 'param':
|
||||
return `param(${n.name})`;
|
||||
case 'namedref':
|
||||
return `namedref(${n.name})`;
|
||||
case 'blockcall':
|
||||
// never appears in freshly-parsed input (see parser.ts's comment on
|
||||
// BlockCallNode) -- exhaustiveness only, no test exercises this.
|
||||
return `blockcall(${n.label})`;
|
||||
case 'omitted':
|
||||
return 'omitted';
|
||||
case 'json':
|
||||
// never produced by the parser (see parser.ts's comment on
|
||||
// JsonLiteralNode) -- exhaustiveness only, no test exercises this.
|
||||
return `json(${JSON.stringify(n.value)})`;
|
||||
}
|
||||
}
|
||||
const parse = (s: string) => sexpr(parseFormula(s));
|
||||
const parse2 = (s: string, paramNames: readonly string[]) => sexpr(parseFormula(s, paramNames));
|
||||
|
||||
/** Compact rendering of a block AST (see parseFunctionBlock) for assertions. */
|
||||
function sexprStmt(s: Statement): string {
|
||||
switch (s.kind) {
|
||||
case 'assign':
|
||||
return `${s.name}=${sexpr(s.value)}`;
|
||||
case 'return':
|
||||
return `RETURN ${sexpr(s.value)}`;
|
||||
case 'for':
|
||||
return `FOR(${s.varName} IN ${sexpr(s.source)} DO ${sexprBlock(s.body)})`;
|
||||
case 'ifstmt':
|
||||
return `IF(${sexpr(s.cond)} THEN ${sexprBlock(s.then)}${s.else ? ` ELSE ${sexprBlock(s.else)}` : ''})`;
|
||||
case 'action':
|
||||
return `ACTION(${s.name};${s.args.map(sexpr).join(';')})`;
|
||||
}
|
||||
}
|
||||
function sexprBlock(b: BlockNode): string {
|
||||
return `{${b.statements.map(sexprStmt).join('; ')}}`;
|
||||
}
|
||||
const parseBlock = (s: string, paramNames: readonly string[]) =>
|
||||
sexprBlock(parseFunctionBlock(s, paramNames));
|
||||
|
||||
describe('parser precedence & associativity', () => {
|
||||
it('* / bind tighter than + -', () => {
|
||||
expect(parse('=1+2*3')).toBe('(+ 1 (* 2 3))');
|
||||
expect(parse('=1-2/2')).toBe('(- 1 (/ 2 2))');
|
||||
});
|
||||
|
||||
it('+ - are left-associative', () => {
|
||||
expect(parse('=1-2-3')).toBe('(- (- 1 2) 3)');
|
||||
});
|
||||
|
||||
it('^ binds tighter than * / and is right-associative', () => {
|
||||
expect(parse('=2*3^2')).toBe('(* 2 (^ 3 2))');
|
||||
expect(parse('=2^3^2')).toBe('(^ 2 (^ 3 2))');
|
||||
});
|
||||
|
||||
it('unary minus binds weaker than ^ (-2^2 = -(2^2))', () => {
|
||||
expect(parse('=-2^2')).toBe('(- (^ 2 2))');
|
||||
expect(parse('=2^-3')).toBe('(^ 2 (- 3))');
|
||||
});
|
||||
|
||||
it('parentheses override precedence', () => {
|
||||
expect(parse('=(1+2)*3')).toBe('(* (+ 1 2) 3)');
|
||||
expect(parse('=(B2+C2)*2-D2/4')).toBe(
|
||||
'(- (* (+ cell(1,1) cell(2,1)) 2) (/ cell(3,1) 4))',
|
||||
);
|
||||
});
|
||||
|
||||
it('& binds looser than + - but tighter than a comparison', () => {
|
||||
expect(parse('="a"&1+2')).toBe('(& "a" (+ 1 2))');
|
||||
expect(parse('=A1&B1="ab"')).toBe('(= (& cell(0,0) cell(1,0)) "ab")');
|
||||
});
|
||||
|
||||
it('& is left-associative', () => {
|
||||
expect(parse('="a"&"b"&"c"')).toBe('(& (& "a" "b") "c")');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser references', () => {
|
||||
it('parses local cells and ranges', () => {
|
||||
expect(parse('=A1')).toBe('cell(0,0)');
|
||||
expect(parse('=A1:C5')).toBe('range(0,0:2,4)');
|
||||
});
|
||||
|
||||
it('parses $-locked (absolute/mixed) references to the same coords -- evaluation is positional', () => {
|
||||
expect(parse('=$A$1')).toBe('cell(0,0)');
|
||||
expect(parse('=A$1')).toBe('cell(0,0)');
|
||||
expect(parse('=$A1')).toBe('cell(0,0)');
|
||||
expect(parse('=$A$1:B$2')).toBe('range(0,0:1,1)');
|
||||
});
|
||||
|
||||
it('parses table-qualified refs, bare and quoted', () => {
|
||||
expect(parse('=Umsatz!B2')).toBe('Umsatz!cell(1,1)');
|
||||
expect(parse("='Kosten Q3'!B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
|
||||
});
|
||||
|
||||
it('accepts the Numbers-style "::" qualifier interchangeably with "!"', () => {
|
||||
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
|
||||
expect(parse("='Kosten Q3'::B2:E2")).toBe('Kosten Q3!range(1,1:4,1)');
|
||||
expect(parse('=Umsatz::B2')).toBe(parse('=Umsatz!B2'));
|
||||
});
|
||||
|
||||
it('parses function calls with ; separator and mixed args', () => {
|
||||
expect(parse('=SUM(Umsatz!B2:E2; 100; A1)')).toBe(
|
||||
'(SUM Umsatz!range(1,1:4,1) 100 cell(0,0))',
|
||||
);
|
||||
});
|
||||
|
||||
it('collectRefs finds every reference', () => {
|
||||
const refs = collectRefs(parseFormula('=SUM(A1:A3; Kosten!B2) + C4'));
|
||||
expect(refs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('an empty argument slot parses as "omitted" -- leading, middle, trailing', () => {
|
||||
expect(parse('=XLOOKUP(1;A1;B1;;-1)')).toBe(
|
||||
'(XLOOKUP 1 cell(0,0) cell(1,0) omitted (- 1))',
|
||||
);
|
||||
expect(parse('=SUM(;5)')).toBe('(SUM omitted 5)');
|
||||
expect(parse('=SUM(5;)')).toBe('(SUM 5 omitted)');
|
||||
expect(parse('=SUM()')).toBe('(SUM )');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser whole-column / whole-row references', () => {
|
||||
const range = (src: string) => parseFormula(src) as Extract<Node, { kind: 'range' }>;
|
||||
|
||||
it('B:B, A:C and $B:$B are whole columns', () => {
|
||||
expect(range('B:B')).toMatchObject({ kind: 'range', whole: 'col', qualifier: null, start: { col: 1, row: 0 } });
|
||||
expect(range('A:C')).toMatchObject({ whole: 'col', start: { col: 0 }, end: { col: 2 } });
|
||||
expect(range('$B:$B')).toMatchObject({ whole: 'col', start: { col: 1 }, end: { col: 1 } });
|
||||
expect(range('aa:ab')).toMatchObject({ whole: 'col', start: { col: 26 }, end: { col: 27 } });
|
||||
});
|
||||
|
||||
it('1:1, 2:5 and $3:$3 are whole rows (1-based in the text)', () => {
|
||||
expect(range('1:1')).toMatchObject({ kind: 'range', whole: 'row', start: { col: 0, row: 0 }, end: { row: 0 } });
|
||||
expect(range('2:5')).toMatchObject({ whole: 'row', start: { row: 1 }, end: { row: 4 } });
|
||||
expect(range('$3:$3')).toMatchObject({ whole: 'row', start: { row: 2 }, end: { row: 2 } });
|
||||
});
|
||||
|
||||
it('take table and sheet qualifiers', () => {
|
||||
expect(range('Umsatz!B:B')).toMatchObject({ qualifier: 'Umsatz', whole: 'col' });
|
||||
expect(range('Umsatz::1:1')).toMatchObject({ qualifier: 'Umsatz', whole: 'row' });
|
||||
expect(range("'Blatt 2'!Kosten::A:C")).toMatchObject({ sheetQualifier: 'Blatt 2', qualifier: 'Kosten', whole: 'col' });
|
||||
});
|
||||
|
||||
it('work as function arguments and are collected as refs', () => {
|
||||
const ast = parseFormula('SUM(B:B; 2:3) + 1');
|
||||
expect(collectRefs(ast).map((r) => (r.kind === 'range' ? r.whole : r.kind))).toEqual(['col', 'row']);
|
||||
});
|
||||
|
||||
it('reject mixed or invalid shapes', () => {
|
||||
expect(() => parseFormula('A:1')).toThrow();
|
||||
expect(() => parseFormula('0:0')).toThrow();
|
||||
// a plain number still is one
|
||||
expect(parseFormula('12')).toEqual({ kind: 'number', value: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser sheet qualifier -- "Blatt!Tabelle::Zelle"', () => {
|
||||
it('parses the explicit 3-part form, bare and quoted', () => {
|
||||
expect(parse('=Analyse!Umsatz::B2')).toBe('Analyse!Umsatz!cell(1,1)');
|
||||
expect(parse("='Meine Analyse'!Umsatz::B2")).toBe('Meine Analyse!Umsatz!cell(1,1)');
|
||||
expect(parse("=Analyse!'Kosten Q3'::B2:E2")).toBe('Analyse!Kosten Q3!range(1,1:4,1)');
|
||||
expect(parse("='Meine Analyse'!'Kosten Q3'::B2")).toBe('Meine Analyse!Kosten Q3!cell(1,1)');
|
||||
});
|
||||
|
||||
it('is unaffected in a range end / function call', () => {
|
||||
expect(parse('=SUM(Analyse!Umsatz::B2:E2; 1)')).toBe('(SUM Analyse!Umsatz!range(1,1:4,1) 1)');
|
||||
});
|
||||
|
||||
it('a bare "X!Y" (no further "::") keeps its original, table-only meaning -- fully backward-compatible', () => {
|
||||
// no "::" ever follows -> "Analyse" is read as the TABLE, "A1" the cell,
|
||||
// exactly as parseFormula() behaved before the sheet qualifier existed.
|
||||
expect(parse('=Analyse!A1')).toBe('Analyse!cell(0,0)');
|
||||
expect(parse('=Umsatz!B2')).toBe(parse('=Umsatz!B2')); // unchanged from the existing suite
|
||||
});
|
||||
|
||||
it('"X::Y" (no leading sheet) is still the plain table qualifier, never a sheet one', () => {
|
||||
expect(parse('=Umsatz::B2')).toBe('Umsatz!cell(1,1)');
|
||||
});
|
||||
|
||||
it('"X!Y!Z" (a second "!" instead of "::") does not form a sheet qualifier -- "!" only introduces a sheet name when followed by NAME "::"', () => {
|
||||
// no "::" anywhere ahead of "Y" -> "X!" is read as an ordinary (old-style)
|
||||
// table qualifier, leaving "Y" to be parsed as the cell -- which it isn't
|
||||
// shaped like (no digits) -> ParseError, same as e.g. "=X!Y" always was.
|
||||
expect(() => parseFormula('=X!Y!Z1')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('rejects a malformed 3-part reference (missing cell after "::")', () => {
|
||||
expect(() => parseFormula('=Analyse!Umsatz::')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFormula(src, paramNames) -- user-defined function bodies', () => {
|
||||
it('a name in paramNames parses as a param reference, case-insensitively', () => {
|
||||
expect(parse2('=revenue - cost', ['revenue', 'cost'])).toBe('(- param(revenue) param(cost))');
|
||||
expect(parse2('=REVENUE * 2', ['revenue'])).toBe('(* param(REVENUE) 2)');
|
||||
});
|
||||
|
||||
it('a bare name not in paramNames still behaves exactly as without the list', () => {
|
||||
expect(() => parseFormula('=revenue - cost', ['revenue'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('without paramNames, a bare non-cell identifier is a named-range reference, not a parse error', () => {
|
||||
// same string, no param list -- "revenue" doesn't look like a cell, but
|
||||
// this is now an ordinary top-level formula, where that's a named-range
|
||||
// candidate (see the "named ranges" describe block below) rather than a
|
||||
// ParseError -- unlike inside a function body (the case just above),
|
||||
// where the fallback is deliberately off.
|
||||
expect(parse('=revenue')).toBe('namedref(revenue)');
|
||||
});
|
||||
|
||||
it('a param can mix with real cell references in the same formula', () => {
|
||||
expect(parse2('=x + A1', ['x'])).toBe('(+ param(x) cell(0,0))');
|
||||
});
|
||||
|
||||
it('a param is never qualified or part of a range -- those still need a real cell', () => {
|
||||
expect(() => parseFormula('=Umsatz!x', ['x'])).toThrow(ParseError);
|
||||
expect(() => parseFormula('=x:B2', ['x'])).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser errors', () => {
|
||||
it('throws ParseError on a dangling operator', () => {
|
||||
expect(() => parseFormula('=A1+')).toThrow(ParseError);
|
||||
});
|
||||
it('throws on an unbalanced paren', () => {
|
||||
expect(() => parseFormula('=SUM(A1')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser named-range references (NamedRefNode)', () => {
|
||||
it('a bare, unqualified, non-cell identifier parses as a namedref', () => {
|
||||
expect(parse('=foo + 1')).toBe('(+ namedref(foo) 1)');
|
||||
expect(parse('=Umsatz_Q1')).toBe('namedref(Umsatz_Q1)');
|
||||
});
|
||||
|
||||
it('mixes with real cell/table references and functions', () => {
|
||||
expect(parse('=SUM(Umsatz_Q1; A1; Umsatz!B2)')).toBe(
|
||||
'(SUM namedref(Umsatz_Q1) cell(0,0) Umsatz!cell(1,1))',
|
||||
);
|
||||
});
|
||||
|
||||
it('a qualified non-cell identifier is still a ParseError -- a named range is always unqualified', () => {
|
||||
expect(() => parseFormula('=Umsatz!foo')).toThrow(ParseError);
|
||||
expect(() => parseFormula("='Kosten Q3'!foo")).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('never a range-start: "Name:B2" is not supported', () => {
|
||||
expect(() => parseFormula('=foo:B2')).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('inside a function body, a bare non-cell/non-param name is still a hard ParseError (no named-range fallback there)', () => {
|
||||
expect(() => parseFormula('=foo', ['bar'])).toThrow(ParseError);
|
||||
expect(() => parseFunctionBlock('RETURN foo', ['bar'])).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser error literals (ErrorLiteralNode)', () => {
|
||||
it('every error code parses as a leaf', () => {
|
||||
expect(parse('=#REF')).toBe('#REF');
|
||||
expect(parse('=#DIV/0!')).toBe('#DIV/0!');
|
||||
expect(parse('=#NAME?')).toBe('#NAME?');
|
||||
expect(parse('=#ERR')).toBe('#ERR');
|
||||
expect(parse('=#CIRC')).toBe('#CIRC');
|
||||
expect(parse('=#VALUE!')).toBe('#VALUE!');
|
||||
expect(parse('=#N/A')).toBe('#N/A');
|
||||
});
|
||||
|
||||
it('composes with operators/functions like any other literal', () => {
|
||||
expect(parse('=1+#REF')).toBe('(+ 1 #REF)');
|
||||
expect(parse('=IF(1=0;#N/A;5)')).toBe('(IF (= 1 0) #N/A 5)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser comparison operators (available in every formula, not just block IF conditions)', () => {
|
||||
it('parses all six comparison operators', () => {
|
||||
expect(parse('=A1=B1')).toBe('(= cell(0,0) cell(1,0))');
|
||||
expect(parse('=A1<>B1')).toBe('(<> cell(0,0) cell(1,0))');
|
||||
expect(parse('=A1>B1')).toBe('(> cell(0,0) cell(1,0))');
|
||||
expect(parse('=A1<B1')).toBe('(< cell(0,0) cell(1,0))');
|
||||
expect(parse('=A1>=B1')).toBe('(>= cell(0,0) cell(1,0))');
|
||||
expect(parse('=A1<=B1')).toBe('(<= cell(0,0) cell(1,0))');
|
||||
});
|
||||
|
||||
it('binds looser than + -', () => {
|
||||
expect(parse('=A1+1=B1')).toBe('(= (+ cell(0,0) 1) cell(1,0))');
|
||||
});
|
||||
|
||||
it('is not chainable -- "a < b < c" is a parse error', () => {
|
||||
expect(() => parseFormula('=1<2<3')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFunctionBlock -- statement grammar for "block" function bodies', () => {
|
||||
it('a single FOR loop, accumulator pattern (DO ... END, one statement)', () => {
|
||||
const src = 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis';
|
||||
expect(parseBlock(src, ['bereich'])).toBe(
|
||||
'{ergebnis=1; FOR(x IN param(bereich) DO {ergebnis=(* param(ergebnis) param(x))}); RETURN param(ergebnis)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('a FOR body with multiple statements', () => {
|
||||
const src = 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total';
|
||||
expect(parseBlock(src, ['bereich'])).toBe(
|
||||
'{total=0; FOR(x IN param(bereich) DO {y=(* param(x) 2); total=(+ param(total) param(y))}); RETURN param(total)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('multiple independent FOR loops in sequence, sharing an accumulator', () => {
|
||||
const src =
|
||||
'total = 0 FOR a IN r1 DO total = total + a END FOR b IN r2 DO total = total + b END RETURN total';
|
||||
expect(parseBlock(src, ['r1', 'r2'])).toBe(
|
||||
'{total=0; FOR(a IN param(r1) DO {total=(+ param(total) param(a))}); ' +
|
||||
'FOR(b IN param(r2) DO {total=(+ param(total) param(b))}); RETURN param(total)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('IF ... THEN ... END, no ELSE', () => {
|
||||
const src = 'x = 1 IF x > 0 THEN x = x + 1 END RETURN x';
|
||||
expect(parseBlock(src, [])).toBe('{x=1; IF((> param(x) 0) THEN {x=(+ param(x) 1)}); RETURN param(x)}');
|
||||
});
|
||||
|
||||
it('IF ... THEN ... ELSE ... END', () => {
|
||||
const src = 'IF x > 0 THEN y = 1 ELSE y = -1 END RETURN y';
|
||||
expect(parseBlock(src, ['x'])).toBe(
|
||||
'{IF((> param(x) 0) THEN {y=1} ELSE {y=(- 1)}); RETURN param(y)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('IF nested inside a FOR body, with an early RETURN -- "if kann in jedem block vorkommen"', () => {
|
||||
const src =
|
||||
'total = 0 FOR x IN bereich DO IF x > 100 THEN RETURN x END total = total + x END RETURN total';
|
||||
expect(parseBlock(src, ['bereich'])).toBe(
|
||||
'{total=0; FOR(x IN param(bereich) DO {IF((> param(x) 100) THEN {RETURN param(x)}); ' +
|
||||
'total=(+ param(total) param(x))}); RETURN param(total)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('a FOR loop nested inside another FOR loop\'s body', () => {
|
||||
const src = 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total';
|
||||
expect(parseBlock(src, ['r1', 'r2'])).toBe(
|
||||
'{total=0; FOR(a IN param(r1) DO {FOR(b IN param(r2) DO ' +
|
||||
'{total=(+ param(total) (* param(a) param(b)))})}); RETURN param(total)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('RETURN may appear anywhere, not just at the end, and may be any expression', () => {
|
||||
expect(parseBlock('RETURN 42', [])).toBe('{RETURN 42}');
|
||||
expect(parseBlock('x = 5 RETURN x * 2', [])).toBe('{x=5; RETURN (* param(x) 2)}');
|
||||
});
|
||||
|
||||
it('a plain assignment can reference an already-declared variable', () => {
|
||||
expect(parseBlock('a = 1 b = a + 1 RETURN b', [])).toBe('{a=1; b=(+ param(a) 1); RETURN param(b)}');
|
||||
});
|
||||
|
||||
it('the FOR source may itself be a cell/range reference, not just a parameter', () => {
|
||||
expect(parseBlock('total = 0 FOR x IN A1:A5 DO total = total + x END RETURN total', [])).toBe(
|
||||
'{total=0; FOR(x IN range(0,0:0,4) DO {total=(+ param(total) param(x))}); RETURN param(total)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('a block without any RETURN still parses -- that is a save-time/runtime concern (blockAlwaysReturns, userFunctions.ts), not a parse error', () => {
|
||||
expect(parseBlock('x = 1', [])).toBe('{x=1}');
|
||||
});
|
||||
|
||||
it('throws on FOR without a matching END', () => {
|
||||
expect(() => parseFunctionBlock('FOR x IN bereich DO y = x RETURN y', ['bereich'])).toThrow(
|
||||
ParseError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on FOR without a matching DO', () => {
|
||||
expect(() => parseFunctionBlock('FOR x IN bereich RETURN x', ['bereich'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('throws on FOR without IN', () => {
|
||||
expect(() => parseFunctionBlock('FOR x bereich DO y = x END RETURN y', ['bereich'])).toThrow(
|
||||
ParseError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on IF without THEN', () => {
|
||||
expect(() => parseFunctionBlock('IF x > 0 x = 1 END RETURN x', ['x'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('throws on IF without a matching END', () => {
|
||||
expect(() => parseFunctionBlock('IF x > 0 THEN x = 1 RETURN x', ['x'])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('throws when an assignment is missing "="', () => {
|
||||
expect(() => parseFunctionBlock('x 1 RETURN x', [])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('a name is only usable after its own declaring statement (self-reference in its own initializer fails)', () => {
|
||||
expect(() => parseFunctionBlock('x = x + 1 RETURN x', [])).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMacroBlock -- write-action statements (ActionNode), macro-only', () => {
|
||||
const parseMacro = (s: string) => sexprBlock(parseMacroBlock(s));
|
||||
|
||||
it('a bare NAME(args) at statement position parses as an action call', () => {
|
||||
expect(parseMacro('SETVALUE(Umsatz::A1; 42)')).toBe(
|
||||
'{ACTION(SETVALUE;Umsatz!cell(0,0);42)}',
|
||||
);
|
||||
});
|
||||
|
||||
it('an action with no arguments', () => {
|
||||
expect(parseMacro('ADDSHEET("Neu")')).toBe('{ACTION(ADDSHEET;"Neu")}');
|
||||
});
|
||||
|
||||
it('";"-separated arguments, including an omitted middle slot', () => {
|
||||
expect(parseMacro('SETVALUE(Umsatz::A1;;)')).toBe('{ACTION(SETVALUE;Umsatz!cell(0,0);omitted;omitted)}');
|
||||
});
|
||||
|
||||
it('an action call composes with local variables and & concatenation', () => {
|
||||
expect(parseMacro('n = "Blatt" ADDSHEET("Neu " & n)')).toBe(
|
||||
'{n="Blatt"; ACTION(ADDSHEET;(& "Neu " param(n)))}',
|
||||
);
|
||||
});
|
||||
|
||||
it('an action call can appear inside FOR/IF bodies', () => {
|
||||
expect(
|
||||
parseMacro('FOR c IN Umsatz::A1:A3 DO SETVALUE(c; 0) END'),
|
||||
).toBe('{FOR(c IN Umsatz!range(0,0:0,2) DO {ACTION(SETVALUE;param(c);0)})}');
|
||||
});
|
||||
|
||||
it('assignment (IDENT "=" expr) still wins over an action call -- disambiguated by the token after the name', () => {
|
||||
expect(parseMacro('x = 1 RETURN x')).toBe('{x=1; RETURN param(x)}');
|
||||
});
|
||||
|
||||
it('a bare NAME(args) at statement position is only recognised in a macro body, never a plain function body', () => {
|
||||
// Same source: a function body has no `allowWrites`, so "SETVALUE(...)"
|
||||
// at statement start falls through to parseAssignment(), which then
|
||||
// fails immediately for lack of "=".
|
||||
expect(() => parseFunctionBlock('SETVALUE(A1; 1) RETURN 1', [])).toThrow(ParseError);
|
||||
});
|
||||
|
||||
it('rejects an action call missing its closing ")"', () => {
|
||||
expect(() => parseMacroBlock('SETVALUE(Umsatz::A1; 1')).toThrow(ParseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectRefsInBlock -- reference walk used by the macro save-time qualifier check', () => {
|
||||
it('collects a SETVALUE action\'s reference arguments too', () => {
|
||||
const block = parseMacroBlock('SETVALUE(Umsatz::A1; Kosten::B2)');
|
||||
const refs = collectRefsInBlock(block);
|
||||
expect(refs.map((r) => r.kind)).toEqual(['cell', 'cell']);
|
||||
expect(refs.map((r) => (r as { qualifier: string | null }).qualifier)).toEqual(['Umsatz', 'Kosten']);
|
||||
});
|
||||
|
||||
it('collects refs from every action argument, not just the first', () => {
|
||||
const block = parseMacroBlock('INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A1; Kosten::B2 + 1)');
|
||||
const refs = collectRefsInBlock(block);
|
||||
// INSERTROW's args are plain strings/numbers -- no refs from that
|
||||
// statement; SETVALUE contributes both its target and its value's ref.
|
||||
expect(refs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseA1, toA1, cycleReferenceLocks, currentIdentifierAtCaret } from '../refs';
|
||||
|
||||
describe('parseA1 / toA1: $-locked (absolute/mixed) references', () => {
|
||||
it('parseA1 reports which axes are $-locked', () => {
|
||||
expect(parseA1('A1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: false });
|
||||
expect(parseA1('$A$1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: true });
|
||||
expect(parseA1('A$1')).toEqual({ col: 0, row: 0, colAbs: false, rowAbs: true });
|
||||
expect(parseA1('$A1')).toEqual({ col: 0, row: 0, colAbs: true, rowAbs: false });
|
||||
});
|
||||
|
||||
it('toA1 round-trips the lock markers', () => {
|
||||
expect(toA1(0, 0)).toBe('A1');
|
||||
expect(toA1(0, 0, true, true)).toBe('$A$1');
|
||||
expect(toA1(0, 0, false, true)).toBe('A$1');
|
||||
expect(toA1(0, 0, true, false)).toBe('$A1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycleReferenceLocks (F4) -- collapsed caret', () => {
|
||||
it('cycles relative -> absolute -> row-locked -> col-locked -> relative', () => {
|
||||
let f = '=A1';
|
||||
let pos = 3;
|
||||
for (const expected of ['=$A$1', '=A$1', '=$A1', '=A1']) {
|
||||
const res = cycleReferenceLocks(f, pos, pos)!;
|
||||
expect(res.text).toBe(expected);
|
||||
expect(res.start).toBe(res.end); // a collapsed caret in -> a collapsed caret out
|
||||
f = res.text;
|
||||
pos = res.end;
|
||||
}
|
||||
});
|
||||
|
||||
it('toggles only the reference the caret sits in/against', () => {
|
||||
const res = cycleReferenceLocks('=A1+B2', 2, 2)!; // caret inside "A1"
|
||||
expect(res.text).toBe('=$A$1+B2');
|
||||
const res2 = cycleReferenceLocks('=A1+B2', 6, 6)!; // caret inside "B2"
|
||||
expect(res2.text).toBe('=A1+$B$2');
|
||||
});
|
||||
|
||||
it('picks the right side of a range depending on the caret', () => {
|
||||
const left = cycleReferenceLocks('=A1:B2', 2, 2)!;
|
||||
expect(left.text).toBe('=$A$1:B2');
|
||||
const right = cycleReferenceLocks('=A1:B2', 6, 6)!;
|
||||
expect(right.text).toBe('=A1:$B$2');
|
||||
});
|
||||
|
||||
it('returns null when the caret is not on a reference, or the text is not a formula', () => {
|
||||
expect(cycleReferenceLocks('=SUM(1;2)', 5, 5)).toBeNull();
|
||||
expect(cycleReferenceLocks('42', 1, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('never toggles a table-name qualifier, even one shaped like a cell', () => {
|
||||
// "AB12" reads as a cell reference on its own, but here it's a table name
|
||||
const formula = '=AB12!A1';
|
||||
expect(cycleReferenceLocks(formula, 2, 2)).toBeNull(); // caret inside "AB12"
|
||||
expect(cycleReferenceLocks(formula, 7, 7)?.text).toBe('=AB12!$A$1'); // caret inside the actual cell
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycleReferenceLocks (F4) -- a real text selection spanning several refs', () => {
|
||||
it('toggles every reference the selection overlaps, each from its own state', () => {
|
||||
// select "A1+$B2" inside "=A1+$B2+C3" -- C3 sits outside the selection.
|
||||
// A1 (relative) -> $A$1; $B2 (already col-locked-only, the cycle's last
|
||||
// stop before relative) -> B2, its next step back to fully relative.
|
||||
const formula = '=A1+$B2+C3';
|
||||
const start = formula.indexOf('A1');
|
||||
const end = formula.indexOf('$B2') + '$B2'.length;
|
||||
const res = cycleReferenceLocks(formula, start, end)!;
|
||||
expect(res.text).toBe('=$A$1+B2+C3');
|
||||
});
|
||||
|
||||
it('a selection covering the whole formula adjusts all of it in one press', () => {
|
||||
const res = cycleReferenceLocks('=A1+B2', 1, 7)!;
|
||||
expect(res.text).toBe('=$A$1+$B$2');
|
||||
});
|
||||
|
||||
it('re-cycling the same (now resized) selection keeps advancing every reference', () => {
|
||||
let f = '=A1+B2';
|
||||
let sel = cycleReferenceLocks(f, 1, 7)!; // -> $A$1+$B$2
|
||||
f = sel.text;
|
||||
sel = cycleReferenceLocks(f, sel.start, sel.end)!; // -> A$1+B$2
|
||||
expect(sel.text).toBe('=A$1+B$2');
|
||||
});
|
||||
|
||||
it('a selection touching no reference returns null', () => {
|
||||
expect(cycleReferenceLocks('=SUM(1;2)', 4, 8)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentIdentifierAtCaret -- function-name autocomplete detection', () => {
|
||||
it('right after "=", right after an operator, right after "(" or ";"', () => {
|
||||
expect(currentIdentifierAtCaret('=S', 2)).toEqual({ text: 'S', start: 1 });
|
||||
expect(currentIdentifierAtCaret('=A1+S', 5)).toEqual({ text: 'S', start: 4 });
|
||||
expect(currentIdentifierAtCaret('=SUM(A', 6)).toEqual({ text: 'A', start: 5 });
|
||||
expect(currentIdentifierAtCaret('=SUM(1;A', 8)).toEqual({ text: 'A', start: 7 });
|
||||
});
|
||||
|
||||
it('null outside a formula (no leading "=")', () => {
|
||||
expect(currentIdentifierAtCaret('SUM', 3)).toBeNull();
|
||||
});
|
||||
|
||||
it('null once the word looks like a cell reference (has a digit)', () => {
|
||||
expect(currentIdentifierAtCaret('=SUM(A1', 7)).toBeNull();
|
||||
expect(currentIdentifierAtCaret('=A1', 3)).toBeNull();
|
||||
});
|
||||
|
||||
it('null right after an already-open, complete function call', () => {
|
||||
expect(currentIdentifierAtCaret('=SUM(', 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('null once the caret has moved past the word (trailing whitespace)', () => {
|
||||
expect(currentIdentifierAtCaret('=SUM ', 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('only looks at text up to the caret, ignoring what follows it', () => {
|
||||
// caret between "SUM" and "X" in "SUMX" -- same result as if "X" weren't there
|
||||
expect(currentIdentifierAtCaret('=SUMX', 4)).toEqual({ text: 'SUM', start: 1 });
|
||||
});
|
||||
|
||||
it('null on a malformed prefix (unterminated string) rather than throwing', () => {
|
||||
expect(currentIdentifierAtCaret('=SUM("x', 7)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFormula, parseMacroBlock } from '../parser';
|
||||
import {
|
||||
buildFunctionIndex,
|
||||
collectCalledFunctionNames,
|
||||
collectCalledFunctionNamesInBlock,
|
||||
expandMacroBlock,
|
||||
expandUserFunctions,
|
||||
type FunctionDef,
|
||||
} from '../userFunctions';
|
||||
import { Workbook, type TableModel } from '../workbook';
|
||||
import { FormulaError, isError, keyOf } from '../types';
|
||||
|
||||
// same small A1 helper used by workbook.spec.ts
|
||||
function sheet(
|
||||
id: string,
|
||||
name: string,
|
||||
cells: Record<string, string>,
|
||||
sheetName = 'Blatt1',
|
||||
): TableModel {
|
||||
const inputs = new Map<string, string>();
|
||||
for (const [a1, raw] of Object.entries(cells)) {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
|
||||
const row = Number(m[2]) - 1;
|
||||
inputs.set(`${col},${row}`, raw);
|
||||
}
|
||||
return { id, name, sheetName, cols: 8, rows: 30, inputs };
|
||||
}
|
||||
const val = (wb: Workbook, sheetId: string, a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
|
||||
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
|
||||
};
|
||||
|
||||
const def = (
|
||||
partial: Partial<FunctionDef> & Pick<FunctionDef, 'id' | 'name' | 'params' | 'body'>,
|
||||
): FunctionDef => ({ kind: 'expression', ...partial });
|
||||
|
||||
describe('expandUserFunctions (AST-level)', () => {
|
||||
it('substitutes parameters with the caller\'s argument subtrees', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
|
||||
const ast = parseFormula('=MARGE(A1; 5)');
|
||||
const expanded = expandUserFunctions(ast, index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '-',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('an unknown name (built-in or not) is left untouched for evaluate() to turn into #NAME?', () => {
|
||||
const index = buildFunctionIndex([]);
|
||||
const ast = parseFormula('=FOO(1;2)');
|
||||
expect(expandUserFunctions(ast, index)).toEqual(ast);
|
||||
});
|
||||
|
||||
it('a user function may call a built-in, and its arguments are still expanded', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'f1', name: 'TOTAL', params: ['a', 'b'], body: 'SUM(a;b;10)' }),
|
||||
]);
|
||||
const ast = parseFormula('=TOTAL(A1;A2)');
|
||||
const expanded = expandUserFunctions(ast, index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'func',
|
||||
name: 'SUM',
|
||||
args: [
|
||||
{ kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
{ kind: 'cell', qualifier: null, col: 0, row: 1 },
|
||||
{ kind: 'number', value: 10 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('a user function may call another user function, params expanded inside-out', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'inner', name: 'DOUBLE', params: ['x'], body: 'x * 2' }),
|
||||
def({ id: 'outer', name: 'QUAD', params: ['x'], body: 'DOUBLE(DOUBLE(x))' }),
|
||||
]);
|
||||
const ast = parseFormula('=QUAD(A1)');
|
||||
const expanded = expandUserFunctions(ast, index);
|
||||
// DOUBLE(DOUBLE(A1)) -> (A1 * 2) * 2, fully inlined, no func nodes left
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 2 },
|
||||
},
|
||||
right: { kind: 'number', value: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('nested functions with colliding parameter names resolve to the correct scope', () => {
|
||||
// OUTER(x) = INNER(x) + 1, INNER(x) = x * 100 -- both call their param "x"
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'inner', name: 'INNER', params: ['x'], body: 'x * 100' }),
|
||||
def({ id: 'outer', name: 'OUTER', params: ['x'], body: 'INNER(x) + 1' }),
|
||||
]);
|
||||
const ast = parseFormula('=OUTER(A1)');
|
||||
const expanded = expandUserFunctions(ast, index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 100 },
|
||||
},
|
||||
right: { kind: 'number', value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on arity mismatch', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
|
||||
expect(() => expandUserFunctions(parseFormula('=MARGE(A1)'), index)).toThrow();
|
||||
});
|
||||
|
||||
it('throws once expansion depth exceeds the recursion guard (defence in depth for a cycle)', () => {
|
||||
// A calls B, B calls A -- should never be saved (store-level cycle check),
|
||||
// but the engine must still degrade to an error, not a stack overflow.
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'a', name: 'A', params: ['x'], body: 'B(x)' }),
|
||||
def({ id: 'b', name: 'B', params: ['x'], body: 'A(x)' }),
|
||||
]);
|
||||
expect(() => expandUserFunctions(parseFormula('=A(1)'), index)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectCalledFunctionNames', () => {
|
||||
it('finds every function name called anywhere in an AST, upper-cased', () => {
|
||||
const ast = parseFormula('=SUM(A1;OTHER(B1)) + third(1)');
|
||||
expect(collectCalledFunctionNames(ast)).toEqual(new Set(['SUM', 'OTHER', 'THIRD']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandMacroBlock / collectCalledFunctionNamesInBlock: the new "action" statement arm', () => {
|
||||
it('expandMacroBlock resolves a custom-function call nested inside an action argument', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'MARGE', params: ['x', 'y'], body: 'x - y' })]);
|
||||
const block = parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))');
|
||||
const expanded = expandMacroBlock(block, index);
|
||||
const stmt = expanded.statements[0];
|
||||
expect(stmt.kind).toBe('action');
|
||||
if (stmt.kind !== 'action') throw new Error('unreachable');
|
||||
// MARGE(10;3) was inlined (kind:'expression') into its (10 - 3) body --
|
||||
// no more 'func' node calling MARGE survives in the expanded tree.
|
||||
expect(collectCalledFunctionNames(stmt.args[1])).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('expandMacroBlock is a no-op fast path when no custom functions are defined', () => {
|
||||
const block = parseMacroBlock('SETVALUE(Umsatz::A1; 1)');
|
||||
expect(expandMacroBlock(block, buildFunctionIndex([]))).toBe(block); // same reference, not just equal
|
||||
});
|
||||
|
||||
it('collectCalledFunctionNamesInBlock finds a function called inside an action\'s arguments', () => {
|
||||
const block = parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3)) INSERTROW("Umsatz"; COUNT(Umsatz::A1:A3))');
|
||||
expect(collectCalledFunctionNamesInBlock(block)).toEqual(new Set(['MARGE', 'COUNT']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook integration: user-defined functions end-to-end', () => {
|
||||
function fixture(functionDefs: FunctionDef[] = []): Workbook {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
B2: '1200', C2: '800',
|
||||
B9: '=MARGE(B2;C2)',
|
||||
});
|
||||
return new Workbook([umsatz], functionDefs);
|
||||
}
|
||||
|
||||
it('a call is evaluated using the function body', () => {
|
||||
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: '(revenue - cost) / revenue' })]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBeCloseTo((1200 - 800) / 1200, 10);
|
||||
});
|
||||
|
||||
it('recalculates through the function body when a precedent changes', () => {
|
||||
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: '(revenue - cost) / revenue' })]);
|
||||
wb.setCell('s-umsatz', 1, 1, '2000'); // B2: 1200 -> 2000
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBeCloseTo((2000 - 800) / 2000, 10);
|
||||
});
|
||||
|
||||
it('a body referencing a cell that is *not* in the call arguments still creates a dependency edge', () => {
|
||||
const wb = fixture();
|
||||
wb.tables[0].inputs.set('4,0', '3'); // E1 = 3, a "rate" cell not passed as an argument
|
||||
wb.tables[0].inputs.set('1,8', '=WITHRATE(B2)'); // B9 = WITHRATE(B2)
|
||||
const functionDefs = [def({ id: 'f1', name: 'WITHRATE', params: ['x'], body: 'x * E1' })];
|
||||
const wb2 = new Workbook(wb.tables, functionDefs);
|
||||
expect(val(wb2, 's-umsatz', 'B9')).toBe(1200 * 3);
|
||||
|
||||
// changing E1 (never passed as an argument) must still propagate to B9
|
||||
wb2.setCell('s-umsatz', 4, 0, '5');
|
||||
expect(val(wb2, 's-umsatz', 'B9')).toBe(1200 * 5);
|
||||
});
|
||||
|
||||
it('unknown function name (never defined) yields #NAME?, exactly like an unknown built-in', () => {
|
||||
const wb = fixture(); // no functionDefs at all -- MARGE stays unresolved
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
|
||||
});
|
||||
|
||||
it('wrong argument count yields #ERR', () => {
|
||||
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost', 'extra'], body: 'revenue' })]);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
|
||||
it('a built-in name can never be shadowed -- SUM keeps behaving like SUM', () => {
|
||||
// even if a document somehow ended up with a "SUM" custom function
|
||||
// (the store rejects this at save time), the engine still prefers the
|
||||
// built-in -- buildFunctionIndex()/expandUserFunctions() never override it
|
||||
// because functions.ts's own registry is consulted at evaluation time and
|
||||
// wins whenever expandUserFunctions leaves a func node in place... so make
|
||||
// sure the custom "SUM" is simply never invoked from a real formula that
|
||||
// hits the built-in path first is out of scope here; this test instead
|
||||
// documents that a *user* SUM does get inlined when explicitly defined,
|
||||
// since the store -- not the engine -- is what forbids the collision.
|
||||
const index = buildFunctionIndex([def({ id: 'x', name: 'SUM', params: ['a'], body: 'a + 1' })]);
|
||||
const expanded = expandUserFunctions(parseFormula('=SUM(A1)'), index);
|
||||
expect(expanded).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'cell', qualifier: null, col: 0, row: 0 },
|
||||
right: { kind: 'number', value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('errors inside a function body propagate to dependents', () => {
|
||||
const wb = fixture([def({ id: 'f1', name: 'MARGE', params: ['revenue', 'cost'], body: 'revenue / 0' })]);
|
||||
expect(isError(val(wb, 's-umsatz', 'B9'))).toBe(true);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#DIV/0!');
|
||||
});
|
||||
|
||||
it('a parameter can be a range, as long as it lands in a function-call argument position', () => {
|
||||
// SPANNE(bereich) = MAX(bereich) - MIN(bereich) -- "bereich" (ParamNode)
|
||||
// gets substituted with the caller's RangeNode, splicing a normal
|
||||
// MAX(Umsatz!B2:E2) / MIN(Umsatz!B2:E2) call into the expanded AST.
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
B2: '1200', C2: '1450', D2: '1600', E2: '1720',
|
||||
B9: '=SPANNE(B2:E2)',
|
||||
});
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[def({ id: 'f1', name: 'SPANNE', params: ['bereich'], body: 'MAX(bereich) - MIN(bereich)' })],
|
||||
);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1720 - 1200);
|
||||
|
||||
// recalculates when a cell *inside* the range argument changes
|
||||
wb.setCell('s-umsatz', 1, 1, '1000'); // B2: 1200 -> 1000
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1720 - 1000);
|
||||
});
|
||||
|
||||
it('a range parameter used outside a function-call argument position is #VALUE!, same as a bare range in an ordinary formula', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
B2: '1200', C2: '1450',
|
||||
B9: '=BAD(B2:C2)',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'BAD', params: ['bereich'], body: 'bereich + 1' })]);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#VALUE!');
|
||||
});
|
||||
|
||||
it('debugOrder places a function-body-only precedent before its dependent', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
B2: '1200', E1: '3',
|
||||
B9: '=WITHRATE(B2)',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'WITHRATE', params: ['x'], body: 'x * E1' })]);
|
||||
const { order, circular } = wb.debugOrder([keyOf('s-umsatz', 4, 0)]); // dirty: E1
|
||||
expect(circular).toEqual([]);
|
||||
expect(order).toContain(keyOf('s-umsatz', 1, 8)); // B9 depends on E1 too
|
||||
});
|
||||
});
|
||||
|
||||
const productBlock = {
|
||||
params: ['bereich'],
|
||||
kind: 'block' as const,
|
||||
body: 'ergebnis = 1 FOR x IN bereich DO ergebnis = ergebnis * x END RETURN ergebnis',
|
||||
};
|
||||
|
||||
describe('expandUserFunctions (AST-level): kind "block" produces a BlockCallNode', () => {
|
||||
it('wraps a block-kind call in a BlockCallNode carrying args/paramNames/label', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
const expanded = expandUserFunctions(parseFormula('=PRODUCT2(A1:A3)'), index);
|
||||
expect(expanded.kind).toBe('blockcall');
|
||||
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
|
||||
expect(expanded.label).toBe('PRODUCT2');
|
||||
expect(expanded.paramNames).toEqual(['bereich']);
|
||||
expect(expanded.args).toEqual([
|
||||
{
|
||||
kind: 'range',
|
||||
qualifier: null,
|
||||
start: { col: 0, row: 0, colAbs: false, rowAbs: false },
|
||||
end: { col: 0, row: 2, colAbs: false, rowAbs: false },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves a nested call to another (expression-kind) function inside the block before evaluation', () => {
|
||||
const index = buildFunctionIndex([
|
||||
def({ id: 'd1', name: 'DOUBLE', params: ['n'], body: 'n * 2' }),
|
||||
def({
|
||||
id: 's1',
|
||||
name: 'SUMDOUBLED',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR x IN bereich DO total = total + DOUBLE(x) END RETURN total',
|
||||
}),
|
||||
]);
|
||||
const expanded = expandUserFunctions(parseFormula('=SUMDOUBLED(A1:A2)'), index);
|
||||
if (expanded.kind !== 'blockcall') throw new Error('unreachable');
|
||||
const forStmt = expanded.block.statements[1];
|
||||
if (forStmt.kind !== 'for') throw new Error('expected a for statement');
|
||||
const assignInBody = forStmt.body.statements[0];
|
||||
if (assignInBody.kind !== 'assign') throw new Error('expected an assignment');
|
||||
// DOUBLE(x) has already been inlined to "x * 2" -- no FuncNode calling
|
||||
// "DOUBLE" remains anywhere in the block
|
||||
expect(assignInBody.value).toEqual({
|
||||
kind: 'binary',
|
||||
op: '+',
|
||||
left: { kind: 'param', name: 'total' },
|
||||
right: {
|
||||
kind: 'binary',
|
||||
op: '*',
|
||||
left: { kind: 'param', name: 'x' },
|
||||
right: { kind: 'number', value: 2 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('arity mismatch is still checked for block-kind functions', () => {
|
||||
const index = buildFunctionIndex([def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
expect(() => expandUserFunctions(parseFormula('=PRODUCT2(A1;A2)'), index)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook integration: block-kind (FOR/IF/RETURN "code") user-defined functions', () => {
|
||||
it('evaluates a PRODUCT-style reduction over a range correctly', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
|
||||
});
|
||||
|
||||
it('recalculates when a cell inside the passed range changes', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(A1:A3)' });
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||
});
|
||||
|
||||
it('a caller may pass a named range as the argument -- the FOR loop iterates it exactly like an ordinary range', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', A3: '4', B9: '=PRODUCT2(Werte)' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[def({ id: 'f1', name: 'PRODUCT2', ...productBlock })],
|
||||
[{ id: 'n1', name: 'Werte', tableId: 's-umsatz', start: { col: 0, row: 0 }, end: { col: 0, row: 2 } }],
|
||||
);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(24);
|
||||
wb.setCell('s-umsatz', 0, 0, '10'); // A1: 2 -> 10 -- still tracked as a precedent via the named range
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(10 * 3 * 4);
|
||||
});
|
||||
|
||||
it('a FOR body with multiple statements runs all of them, every iteration', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '2', A2: '3', B9: '=DOUBLESUM(A1:A2)' });
|
||||
const doubleSum = def({
|
||||
id: 'f1',
|
||||
name: 'DOUBLESUM',
|
||||
params: ['bereich'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR x IN bereich DO y = x * 2 total = total + y END RETURN total',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [doubleSum]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 2 + 3 * 2);
|
||||
});
|
||||
|
||||
it('a FOR loop nested inside another FOR loop evaluates correctly, not just parses', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
A1: '2',
|
||||
A2: '3',
|
||||
B1: '10',
|
||||
B2: '20',
|
||||
B9: '=OUTERPROD(A1:A2; B1:B2)',
|
||||
});
|
||||
const outerProd = def({
|
||||
id: 'f1',
|
||||
name: 'OUTERPROD',
|
||||
params: ['r1', 'r2'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR a IN r1 DO FOR b IN r2 DO total = total + a * b END END RETURN total',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [outerProd]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(2 * 10 + 2 * 20 + 3 * 10 + 3 * 20);
|
||||
});
|
||||
|
||||
it('IF/ELSE branches on the real, current cell value', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=SIGN2(A1)' });
|
||||
const sign2 = def({
|
||||
id: 'f1',
|
||||
name: 'SIGN2',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'IF x > 0 THEN RETURN 1 ELSE RETURN -1 END',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [sign2]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
|
||||
wb.setCell('s-umsatz', 0, 0, '-5');
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(-1);
|
||||
});
|
||||
|
||||
it('comparison operators work inside an IF condition', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '10', A2: '10', B9: '=EQ2(A1;A2)' });
|
||||
const eq2 = def({
|
||||
id: 'f1',
|
||||
name: 'EQ2',
|
||||
params: ['a', 'b'],
|
||||
kind: 'block',
|
||||
body: 'IF a = b THEN RETURN 1 ELSE RETURN 0 END',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [eq2]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
|
||||
wb.setCell('s-umsatz', 0, 1, '11'); // A2: 10 -> 11
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(0);
|
||||
});
|
||||
|
||||
it('AND/OR compose a compound condition inside an IF, same as any other function call', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', B9: '=INRANGE(A1)' });
|
||||
const inRange = def({
|
||||
id: 'f1',
|
||||
name: 'INRANGE',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'IF AND(x > 0; x < 10) THEN RETURN 1 ELSE RETURN 0 END',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [inRange]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1);
|
||||
wb.setCell('s-umsatz', 0, 0, '20'); // A1: 5 -> 20, out of range
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(0);
|
||||
});
|
||||
|
||||
it('an early RETURN nested inside an IF inside a FOR stops the loop -- finds the first match, never touches later cells', () => {
|
||||
// A3 is the first cell > 100; A4 must never affect the result
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
A1: '1',
|
||||
A2: '2',
|
||||
A3: '999',
|
||||
A4: '4',
|
||||
B9: '=FIRSTOVER(A1:A4; 100)',
|
||||
});
|
||||
const firstOver = def({
|
||||
id: 'f1',
|
||||
name: 'FIRSTOVER',
|
||||
params: ['bereich', 'schwelle'],
|
||||
kind: 'block',
|
||||
body: 'FOR x IN bereich DO IF x > schwelle THEN RETURN x END END RETURN 0',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [firstOver]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(999);
|
||||
// changing the never-reached A4 doesn't change the (already-found) result
|
||||
wb.setCell('s-umsatz', 0, 3, '99999');
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(999);
|
||||
});
|
||||
|
||||
it('a cell referenced only in a not-taken IF branch is still a tracked dependency', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { A1: '5', A2: '1', A3: '2', B9: '=BRANCHY(A1)' });
|
||||
const branchy = def({
|
||||
id: 'f1',
|
||||
name: 'BRANCHY',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'IF x > 0 THEN RETURN A2 ELSE RETURN A3 END',
|
||||
});
|
||||
const wb = new Workbook([umsatz], [branchy]);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1); // A1=5>0 -> takes the A2 branch
|
||||
wb.setCell('s-umsatz', 0, 0, '-5'); // flips to the A3 branch
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(2);
|
||||
});
|
||||
|
||||
it('exceeding the loop-step cap degrades to #ERR at the cell, not a crash', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=PRODUCT2(A1:A20000)' });
|
||||
const wb = new Workbook([umsatz], [def({ id: 'f1', name: 'PRODUCT2', ...productBlock })]);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
|
||||
it('an invalid FOR source degrades to #ERR at the cell, not a crash', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=BAD(5)' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[
|
||||
def({
|
||||
id: 'f1',
|
||||
name: 'BAD',
|
||||
params: ['x'],
|
||||
kind: 'block',
|
||||
body: 'total = 0 FOR y IN x + 1 DO total = total + y END RETURN total',
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
|
||||
it('a function that never reaches RETURN degrades to #ERR, not a crash', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=NORETURN(1)' });
|
||||
const noReturn = def({ id: 'f1', name: 'NORETURN', params: ['x'], kind: 'block', body: 'y = x + 1' });
|
||||
const wb = new Workbook([umsatz], [noReturn]);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#ERR');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Workbook, literalValue, type TableModel } from '../workbook';
|
||||
import { FormulaError, isError, keyOf } from '../types';
|
||||
import { dateToSerial, timeSerial, todaySerial, nowSerial } from '../dates';
|
||||
|
||||
function sheet(
|
||||
id: string,
|
||||
name: string,
|
||||
cells: Record<string, string>,
|
||||
sheetName = 'Blatt1',
|
||||
): TableModel {
|
||||
const inputs = new Map<string, string>();
|
||||
for (const [a1, raw] of Object.entries(cells)) {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
|
||||
const row = Number(m[2]) - 1;
|
||||
inputs.set(`${col},${row}`, raw);
|
||||
}
|
||||
return { id, name, sheetName, cols: 8, rows: 30, inputs };
|
||||
}
|
||||
|
||||
/** Rebuilds the small "Umsatz / Kosten / Übersicht" fixture from the brief. */
|
||||
function fixture(): Workbook {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', {
|
||||
A2: 'Nord', B2: '1200', C2: '1450', D2: '1600', E2: '1720',
|
||||
A3: 'Süd', B3: '980', C3: '1100', D3: '1250', E3: '1310',
|
||||
A4: 'Ost', B4: '1550', C4: '1480', D4: '1620', E4: '1700',
|
||||
A5: 'West', B5: '1340', C5: '1390', D5: '1500', E5: '1580',
|
||||
A6: 'Zentral', B6: '1105', C6: '1205', D6: '1298', E6: '1402',
|
||||
B8: '=SUM(B2:B6)', B9: '=AVG(B2:B6)', B10: '=MIN(B2:B6)',
|
||||
B11: '=MAX(B2:B6)', B12: '=COUNT(B2:B6)',
|
||||
H2: '=SUM(B2:E2)', H3: '=SUM(B2:E2)/4', H4: '=E2-B2',
|
||||
});
|
||||
const kosten = sheet('s-kosten', 'Kosten', {
|
||||
A2: 'Nord', B2: '800', C2: '820', D2: '900', E2: '880',
|
||||
});
|
||||
const uebersicht = sheet('s-ueber', 'Übersicht', {
|
||||
B2: '=SUM(Umsatz!B2:E2)', C2: '=SUM(Kosten!B2:E2)',
|
||||
D2: '=B2-C2', E2: '=D2/B2*100',
|
||||
B8: '=SUM(B2:B6)', D8: '=SUM(D2:D6)',
|
||||
});
|
||||
return new Workbook([umsatz, kosten, uebersicht]);
|
||||
}
|
||||
|
||||
const val = (wb: Workbook, sheetId: string, a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
const col = m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1;
|
||||
return wb.cellValue(sheetId, col, Number(m[2]) - 1);
|
||||
};
|
||||
|
||||
describe('Workbook aggregates', () => {
|
||||
const wb = fixture();
|
||||
it('SUM/AVG/MIN/MAX/COUNT of Umsatz!B2:B6', () => {
|
||||
expect(val(wb, 's-umsatz', 'B8')).toBe(6175);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(1235);
|
||||
expect(val(wb, 's-umsatz', 'B10')).toBe(980);
|
||||
expect(val(wb, 's-umsatz', 'B11')).toBe(1550);
|
||||
expect(val(wb, 's-umsatz', 'B12')).toBe(5);
|
||||
});
|
||||
it('row helpers', () => {
|
||||
expect(val(wb, 's-umsatz', 'H2')).toBe(5970); // 1200+1450+1600+1720
|
||||
expect(val(wb, 's-umsatz', 'H3')).toBe(1492.5);
|
||||
expect(val(wb, 's-umsatz', 'H4')).toBe(520);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook cross-sheet', () => {
|
||||
it('resolves Name!Range across sheets', () => {
|
||||
const wb = fixture();
|
||||
expect(val(wb, 's-ueber', 'B2')).toBe(5970);
|
||||
expect(val(wb, 's-ueber', 'C2')).toBe(3400);
|
||||
expect(val(wb, 's-ueber', 'D2')).toBe(2570);
|
||||
expect(val(wb, 's-ueber', 'E2')).toBeCloseTo((2570 / 5970) * 100, 6);
|
||||
});
|
||||
|
||||
it('editing Umsatz!B2 propagates to Übersicht B2/D2/E2 and the Gesamt row', () => {
|
||||
const wb = fixture();
|
||||
wb.setCell('s-umsatz', 1, 1, '2200'); // B2: 1200 -> 2200 (+1000)
|
||||
expect(val(wb, 's-umsatz', 'B8')).toBe(7175);
|
||||
expect(val(wb, 's-ueber', 'B2')).toBe(6970);
|
||||
expect(val(wb, 's-ueber', 'D2')).toBe(3570);
|
||||
expect(val(wb, 's-ueber', 'B8')).toBe(6970); // only row 2 populated in fixture
|
||||
expect(val(wb, 's-ueber', 'D8')).toBe(3570);
|
||||
});
|
||||
|
||||
it('is tolerant about sheet-name case and quoting', () => {
|
||||
const wb = fixture();
|
||||
wb.setCell('s-kosten', 0, 8, "='Kosten'!B2"); // A9 = 'Kosten'!B2
|
||||
wb.setCell('s-kosten', 1, 8, '=kosten!b2'); // B9 lower-case
|
||||
expect(val(wb, 's-kosten', 'A9')).toBe(800);
|
||||
expect(val(wb, 's-kosten', 'B9')).toBe(800);
|
||||
});
|
||||
|
||||
it('a table name with non-ASCII letters works as a bare, unquoted qualifier', () => {
|
||||
// regression: "Übersicht" is the fixture's own table name, but nothing
|
||||
// above ever refers *into* it by name (it only ever refers out) -- so
|
||||
// this table has silently been unreferenceable from elsewhere the whole
|
||||
// time (the lexer rejected "Ü" outright; see engine/lexer.ts).
|
||||
const wb = fixture();
|
||||
wb.setCell('s-umsatz', 6, 8, '=Übersicht!B2'); // G9, no quoting needed (no spaces)
|
||||
expect(val(wb, 's-umsatz', 'G9')).toBe(5970);
|
||||
expect(isError(val(wb, 's-umsatz', 'G9'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook error values', () => {
|
||||
const wb = fixture();
|
||||
it('classifies each error kind', () => {
|
||||
wb.setCell('s-umsatz', 6, 20, '=1/0');
|
||||
wb.setCell('s-umsatz', 6, 21, '=FOO(1)');
|
||||
wb.setCell('s-umsatz', 6, 22, '=A1+');
|
||||
wb.setCell('s-umsatz', 6, 23, '=Nix!A1');
|
||||
expect((val(wb, 's-umsatz', 'G21') as FormulaError).code).toBe('#DIV/0!');
|
||||
expect((val(wb, 's-umsatz', 'G22') as FormulaError).code).toBe('#NAME?');
|
||||
expect((val(wb, 's-umsatz', 'G23') as FormulaError).code).toBe('#ERR');
|
||||
expect((val(wb, 's-umsatz', 'G24') as FormulaError).code).toBe('#REF');
|
||||
});
|
||||
|
||||
it('detects a direct circular reference', () => {
|
||||
const wb2 = fixture();
|
||||
wb2.setCell('s-umsatz', 0, 0, '=A1');
|
||||
expect((val(wb2, 's-umsatz', 'A1') as FormulaError).code).toBe('#CIRC');
|
||||
});
|
||||
|
||||
it('detects a cross-sheet circular reference', () => {
|
||||
const wb2 = fixture();
|
||||
wb2.setCell('s-umsatz', 0, 0, "='Kosten'!Z9");
|
||||
wb2.setCell('s-kosten', 25, 8, '=Umsatz!A1');
|
||||
expect((val(wb2, 's-umsatz', 'A1') as FormulaError).code).toBe('#CIRC');
|
||||
expect((val(wb2, 's-kosten', 'Z9') as FormulaError).code).toBe('#CIRC');
|
||||
});
|
||||
|
||||
it('errors propagate to dependents', () => {
|
||||
const wb2 = fixture();
|
||||
wb2.setCell('s-umsatz', 1, 1, '=1/0');
|
||||
expect(isError(val(wb2, 's-umsatz', 'B8'))).toBe(true);
|
||||
expect(isError(val(wb2, 's-ueber', 'B2'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook structural changes', () => {
|
||||
it('deleting a referenced sheet turns dependents into #REF', () => {
|
||||
const wb = fixture();
|
||||
wb.tables = wb.tables.filter((t) => t.id !== 's-kosten');
|
||||
wb.rebuild();
|
||||
expect((val(wb, 's-ueber', 'C2') as FormulaError).code).toBe('#REF');
|
||||
// unrelated cells still fine
|
||||
expect(val(wb, 's-ueber', 'B2')).toBe(5970);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook named ranges', () => {
|
||||
function namedRangeFixture(): Workbook {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=Preis + 1' });
|
||||
return new Workbook(
|
||||
[umsatz],
|
||||
[],
|
||||
[{ id: 'n1', name: 'Preis', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }],
|
||||
);
|
||||
}
|
||||
|
||||
it('resolves a single-cell named range like an ordinary cell reference', () => {
|
||||
const wb = namedRangeFixture();
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(101);
|
||||
});
|
||||
|
||||
it('is a dependency -- recalculates when the referenced cell changes', () => {
|
||||
const wb = namedRangeFixture();
|
||||
wb.setCell('s-umsatz', 1, 1, '500'); // B2: 100 -> 500
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(501);
|
||||
});
|
||||
|
||||
it('a multi-cell named range works as a range function argument', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B2: '100', B3: '200', B9: '=SUM(Bereich)' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[],
|
||||
[{ id: 'n1', name: 'Bereich', tableId: 's-umsatz', start: { col: 1, row: 1 }, end: { col: 1, row: 2 } }],
|
||||
);
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(300);
|
||||
});
|
||||
|
||||
it('survives its target table being renamed (resolved live by id, not by the name baked into the formula)', () => {
|
||||
const wb = namedRangeFixture();
|
||||
wb.tables[0].name = 'Verkauf'; // rename the table Preis points at
|
||||
wb.rebuild();
|
||||
expect(val(wb, 's-umsatz', 'B9')).toBe(101); // still resolves -- no #NAME?/#REF
|
||||
});
|
||||
|
||||
it('an unknown name is #NAME?', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=DoesNotExist' });
|
||||
const wb = new Workbook([umsatz], [], []);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
|
||||
});
|
||||
|
||||
it('a dangling tableId (target table deleted) is #NAME?, same as a name that never existed', () => {
|
||||
const umsatz = sheet('s-umsatz', 'Umsatz', { B9: '=Preis' });
|
||||
const wb = new Workbook(
|
||||
[umsatz],
|
||||
[],
|
||||
[{ id: 'n1', name: 'Preis', tableId: 'no-such-table', start: { col: 1, row: 1 }, end: { col: 1, row: 1 } }],
|
||||
);
|
||||
expect((val(wb, 's-umsatz', 'B9') as FormulaError).code).toBe('#NAME?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook graph queries (graphPrecedents/graphDependents)', () => {
|
||||
const coords = (a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
return { col: m[1].split('').reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - 64), 0) - 1, row: Number(m[2]) - 1 };
|
||||
};
|
||||
|
||||
it('graphPrecedents lists exactly the cells a formula reads, same-table', () => {
|
||||
const wb = fixture();
|
||||
const { col, row } = coords('D2'); // Übersicht!D2 = B2-C2
|
||||
const precs = wb.graphPrecedents('s-ueber', col, row);
|
||||
expect(precs).toHaveLength(2);
|
||||
expect(precs).toContainEqual({ tableId: 's-ueber', ...coords('B2') });
|
||||
expect(precs).toContainEqual({ tableId: 's-ueber', ...coords('C2') });
|
||||
});
|
||||
|
||||
it('graphPrecedents follows a cross-table range reference to every cell in it', () => {
|
||||
const wb = fixture();
|
||||
const { col, row } = coords('B2'); // Übersicht!B2 = SUM(Umsatz!B2:E2)
|
||||
const precs = wb.graphPrecedents('s-ueber', col, row);
|
||||
expect(precs).toHaveLength(4);
|
||||
for (const a1 of ['B2', 'C2', 'D2', 'E2']) {
|
||||
expect(precs).toContainEqual({ tableId: 's-umsatz', ...coords(a1) });
|
||||
}
|
||||
});
|
||||
|
||||
it('graphDependents is the exact reverse of graphPrecedents', () => {
|
||||
const wb = fixture();
|
||||
const { col, row } = coords('B2'); // Übersicht!B2 -- read by D2 and (via D2) E2 directly reads B2 too
|
||||
const deps = wb.graphDependents('s-ueber', col, row);
|
||||
expect(deps).toContainEqual({ tableId: 's-ueber', ...coords('D2') });
|
||||
expect(deps).toContainEqual({ tableId: 's-ueber', ...coords('E2') }); // E2 = D2/B2*100, reads B2 directly
|
||||
});
|
||||
|
||||
it('a cell with no formula (or a literal) has no precedents', () => {
|
||||
const wb = fixture();
|
||||
const { col, row } = coords('B2'); // Umsatz!B2 is a plain literal, "1200"
|
||||
expect(wb.graphPrecedents('s-umsatz', col, row)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a cell nothing reads has no dependents', () => {
|
||||
const wb = fixture();
|
||||
const { col, row } = coords('A2'); // Umsatz!A2 = "Nord", never referenced by a formula
|
||||
expect(wb.graphDependents('s-umsatz', col, row)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stays accurate for cells on a circular reference (edges exist even though topoOrder drops them)', () => {
|
||||
const a = sheet('s1', 'S1', { A1: '=B1', B1: '=A1' });
|
||||
const wb = new Workbook([a]);
|
||||
const a1 = coords('A1');
|
||||
expect(wb.graphPrecedents('s1', a1.col, a1.row)).toContainEqual({ tableId: 's1', ...coords('B1') });
|
||||
expect(wb.graphDependents('s1', a1.col, a1.row)).toContainEqual({ tableId: 's1', ...coords('B1') });
|
||||
});
|
||||
|
||||
it('updates live after a cell is re-edited to reference something else', () => {
|
||||
const wb = fixture();
|
||||
const c1 = coords('C1');
|
||||
wb.setCell('s-umsatz', c1.col, c1.row, '=B2');
|
||||
expect(wb.graphPrecedents('s-umsatz', c1.col, c1.row)).toContainEqual({ tableId: 's-umsatz', ...coords('B2') });
|
||||
wb.setCell('s-umsatz', c1.col, c1.row, '=B3');
|
||||
const precs = wb.graphPrecedents('s-umsatz', c1.col, c1.row);
|
||||
expect(precs).toContainEqual({ tableId: 's-umsatz', ...coords('B3') });
|
||||
expect(precs).not.toContainEqual({ tableId: 's-umsatz', ...coords('B2') });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook recalculation order', () => {
|
||||
it('evaluates precedents before dependents (chain across sheets)', () => {
|
||||
const wb = fixture();
|
||||
// chain: Umsatz!B2 -> Übersicht!B2 -> Übersicht!D2 -> Übersicht!E2
|
||||
const { order, circular } = wb.debugOrder([keyOf('s-umsatz', 1, 1)]);
|
||||
expect(circular).toEqual([]);
|
||||
const pos = (k: string) => order.indexOf(k);
|
||||
expect(pos(keyOf('s-ueber', 1, 1))).toBeGreaterThan(-1);
|
||||
expect(pos(keyOf('s-ueber', 1, 1))).toBeLessThan(pos(keyOf('s-ueber', 3, 1)));
|
||||
expect(pos(keyOf('s-ueber', 3, 1))).toBeLessThan(pos(keyOf('s-ueber', 4, 1)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook sheet qualifier ("Blatt!Tabelle::Zelle") -- table names unique per sheet only', () => {
|
||||
/** Two sheets ("Analyse", "Zweitblatt") each hold their own table called
|
||||
* "Umsatz" -- the same table NAME on two different sheets, only possible
|
||||
* now that table-name uniqueness is per-sheet rather than document-wide. */
|
||||
function multiSheetFixture(): Workbook {
|
||||
const analyseUmsatz = sheet('s-a-umsatz', 'Umsatz', { B2: '100' }, 'Analyse');
|
||||
const analyseOther = sheet(
|
||||
's-a-other',
|
||||
'Auswertung',
|
||||
{
|
||||
B2: '=Umsatz!B2', // ambiguous name, home sheet is Analyse -> Analyse's own Umsatz
|
||||
B3: '=Analyse!Umsatz::B2', // explicit sheet qualifier
|
||||
B4: '=Zweitblatt!Umsatz::B2', // explicit sheet qualifier, the OTHER sheet's Umsatz
|
||||
},
|
||||
'Analyse',
|
||||
);
|
||||
const zweitUmsatz = sheet('s-b-umsatz', 'Umsatz', { B2: '999' }, 'Zweitblatt');
|
||||
const zweitOther = sheet(
|
||||
's-b-other',
|
||||
'Check',
|
||||
{ B2: '=Umsatz!B2' }, // ambiguous name, home sheet is Zweitblatt -> Zweitblatt's own Umsatz
|
||||
'Zweitblatt',
|
||||
);
|
||||
const drittOther = sheet(
|
||||
's-c-other',
|
||||
'Fremd',
|
||||
{
|
||||
B2: '=Umsatz!B2', // ambiguous, no same-sheet "Umsatz" on Dritt -> #REF
|
||||
B3: '=Auswertung!B2', // "Auswertung" itself is NOT ambiguous (only on Analyse) -> resolves from anywhere
|
||||
},
|
||||
'Dritt',
|
||||
);
|
||||
return new Workbook([analyseUmsatz, analyseOther, zweitUmsatz, zweitOther, drittOther]);
|
||||
}
|
||||
|
||||
it('an unqualified, otherwise-ambiguous name resolves to the table on the SAME sheet as the formula', () => {
|
||||
const wb = multiSheetFixture();
|
||||
expect(val(wb, 's-a-other', 'B2')).toBe(100);
|
||||
expect(val(wb, 's-b-other', 'B2')).toBe(999);
|
||||
});
|
||||
|
||||
it('an explicit "Blatt!Tabelle::Zelle" always resolves to that sheet\'s table, regardless of home sheet', () => {
|
||||
const wb = multiSheetFixture();
|
||||
expect(val(wb, 's-a-other', 'B3')).toBe(100);
|
||||
expect(val(wb, 's-a-other', 'B4')).toBe(999);
|
||||
});
|
||||
|
||||
it('an unqualified ambiguous name with no same-sheet match is #REF', () => {
|
||||
const wb = multiSheetFixture();
|
||||
expect((val(wb, 's-c-other', 'B2') as FormulaError).code).toBe('#REF');
|
||||
});
|
||||
|
||||
it('a non-ambiguous (single-match) name still resolves without any sheet qualifier, from any sheet', () => {
|
||||
const wb = multiSheetFixture();
|
||||
expect(val(wb, 's-c-other', 'B3')).toBe(100); // Auswertung!B2, transitively Analyse's Umsatz!B2
|
||||
});
|
||||
|
||||
it('recalculates correctly through a same-sheet-preferred resolution when the source cell changes', () => {
|
||||
const wb = multiSheetFixture();
|
||||
wb.setCell('s-a-umsatz', 1, 1, '250'); // Analyse's Umsatz B2: 100 -> 250
|
||||
expect(val(wb, 's-a-other', 'B2')).toBe(250);
|
||||
expect(val(wb, 's-a-other', 'B3')).toBe(250); // Auswertung!B2 chained through
|
||||
expect(val(wb, 's-b-other', 'B2')).toBe(999); // Zweitblatt's own Umsatz, unaffected
|
||||
});
|
||||
|
||||
it('an explicit sheet qualifier for an unknown sheet or table is #REF', () => {
|
||||
const wb2 = new Workbook([
|
||||
sheet('s-x', 'X', { B2: '=Nirgendwo!Umsatz::A1' }, 'Analyse'),
|
||||
sheet('s-a-umsatz', 'Umsatz', { A1: '5' }, 'Analyse'),
|
||||
]);
|
||||
expect((val(wb2, 's-x', 'B2') as FormulaError).code).toBe('#REF');
|
||||
});
|
||||
});
|
||||
|
||||
describe('literalValue: date/time literal recognition', () => {
|
||||
it('turns a recognized date/time literal into its serial number', () => {
|
||||
expect(literalValue('2026-09-02')).toBe(dateToSerial(2026, 9, 2));
|
||||
expect(literalValue('02.09.2026')).toBe(dateToSerial(2026, 9, 2));
|
||||
expect(literalValue('14:30')).toBe(timeSerial(14, 30, 0));
|
||||
});
|
||||
|
||||
it('leaves ordinary numbers and text untouched', () => {
|
||||
expect(literalValue('1234.5')).toBe(1234.5);
|
||||
expect(literalValue('-3')).toBe(-3);
|
||||
expect(literalValue('hello')).toBe('hello');
|
||||
expect(literalValue('02/09/2026')).toBe('02/09/2026'); // slash dates: not recognized, stays text
|
||||
});
|
||||
|
||||
it('empty input is null', () => {
|
||||
expect(literalValue('')).toBeNull();
|
||||
expect(literalValue(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook: DATE/TIME/TODAY/NOW formulas', () => {
|
||||
it('DATE/TIME build a serial usable in cell arithmetic', () => {
|
||||
const wb = new Workbook([
|
||||
sheet('s1', 'S1', {
|
||||
A1: '=DATE(2026;9;2)',
|
||||
A2: '=DATE(2026;9;1)',
|
||||
A3: '=A1-A2', // date arithmetic: just numbers
|
||||
B1: '=TIME(12;0;0)',
|
||||
}),
|
||||
]);
|
||||
expect(val(wb, 's1', 'A1')).toBe(dateToSerial(2026, 9, 2));
|
||||
expect(val(wb, 's1', 'A3')).toBe(1);
|
||||
expect(val(wb, 's1', 'B1')).toBeCloseTo(0.5, 10);
|
||||
});
|
||||
|
||||
it('DATE overflows like Excel, TIME wraps like Excel', () => {
|
||||
const wb = new Workbook([
|
||||
sheet('s1', 'S1', { A1: '=DATE(2026;13;1)', B1: '=TIME(25;0;0)' }),
|
||||
]);
|
||||
expect(val(wb, 's1', 'A1')).toBe(dateToSerial(2027, 1, 1));
|
||||
expect(val(wb, 's1', 'B1')).toBeCloseTo(timeSerial(25, 0, 0), 10);
|
||||
});
|
||||
|
||||
it('DATE/TIME reject the wrong argument count or a non-numeric argument', () => {
|
||||
const wb = new Workbook([
|
||||
sheet('s1', 'S1', { A1: '=DATE(2026;9)', A2: '=DATE(2026;9;"x")' }),
|
||||
]);
|
||||
expect(isError(val(wb, 's1', 'A1'))).toBe(true);
|
||||
expect(isError(val(wb, 's1', 'A2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('TODAY/NOW take no arguments and return a plausible current serial', () => {
|
||||
const wb = new Workbook([sheet('s1', 'S1', { A1: '=TODAY()', A2: '=NOW()', A3: '=TODAY(1)' })]);
|
||||
expect(val(wb, 's1', 'A1')).toBe(todaySerial());
|
||||
expect(val(wb, 's1', 'A2') as number).toBeCloseTo(nowSerial(), 2);
|
||||
expect(isError(val(wb, 's1', 'A3'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workbook: XLOOKUP recalculation', () => {
|
||||
it('rewires and recalculates when a cell inside either range changes', () => {
|
||||
const wb = new Workbook([
|
||||
sheet('s1', 'S1', {
|
||||
A2: 'Apfel', B2: '1.5',
|
||||
A3: 'Birne', B3: '2',
|
||||
A4: 'Kirsche', B4: '5',
|
||||
C1: '=XLOOKUP("Birne";A2:A4;B2:B4)',
|
||||
}),
|
||||
]);
|
||||
expect(val(wb, 's1', 'C1')).toBe(2);
|
||||
|
||||
// a change to a cell only inside the RETURN range (not itself the match)
|
||||
wb.setCell('s1', 1, 2, '20'); // B3
|
||||
expect(val(wb, 's1', 'C1')).toBe(20);
|
||||
|
||||
// a change to a cell only inside the LOOKUP range moves the match
|
||||
wb.setCell('s1', 0, 1, 'Birne'); // A2 -> now also "Birne", first match wins
|
||||
expect(val(wb, 's1', 'C1')).toBe(1.5);
|
||||
});
|
||||
|
||||
it('an unrelated cell change does not trigger recalculation', () => {
|
||||
const wb = new Workbook([
|
||||
sheet('s1', 'S1', { A2: 'Apfel', B2: '1.5', C1: '=XLOOKUP("Apfel";A2:A2;B2:B2)', D1: '1' }),
|
||||
]);
|
||||
expect(val(wb, 's1', 'C1')).toBe(1.5);
|
||||
wb.setCell('s1', 3, 0, '999'); // D1, not referenced by C1 at all
|
||||
expect(val(wb, 's1', 'C1')).toBe(1.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Dates & times as plain numbers -- "serial days" since a fixed epoch,
|
||||
* exactly like every other spreadsheet (Excel, Google Sheets, Numbers): the
|
||||
* integer part is the calendar day, the fractional part is the time of day
|
||||
* (0 = midnight, 0.5 = noon). Epoch = 1899-12-30 (the Google Sheets / Excel
|
||||
* convention, WITHOUT Excel's historical 1900-leap-year bug), so serial 1 =
|
||||
* 1899-12-31, serial 2 = 1900-01-01. This is the whole reason date
|
||||
* arithmetic (`=faelligkeit - rechnungsdatum`, `=rechnungsdatum + 30`) just
|
||||
* works with the existing `+`/`-` operators -- no engine change needed
|
||||
* there at all.
|
||||
*
|
||||
* All arithmetic goes through UTC fields (`Date.UTC` / `getUTC*`) so a
|
||||
* serial round-trips exactly regardless of the browser's local timezone.
|
||||
*/
|
||||
|
||||
import { FormulaError, isError, type CellValue } from './types';
|
||||
|
||||
const EPOCH_MS = Date.UTC(1899, 11, 30);
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/** `DATE()`'s building block -- plain `Date.UTC`, overflow allowed on
|
||||
* purpose (`dateToSerial(2026, 13, 1)` = 2027-01-01), matching Excel's
|
||||
* `DATE()`. Contrast with the stricter, overflow-rejecting builder used by
|
||||
* {@link parseDateTimeLiteral} below for *recognizing typed input*. */
|
||||
export function dateToSerial(y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number {
|
||||
return (Date.UTC(y, mo - 1, d, h, mi, s) - EPOCH_MS) / MS_PER_DAY;
|
||||
}
|
||||
|
||||
/** `TIME()`'s building block -- wraps like Excel's TIME (`TIME(25,0,0)` is
|
||||
* the 1:00 fraction of the *next* day, i.e. `0.041666...`). */
|
||||
export function timeSerial(h: number, mi: number, s: number): number {
|
||||
const total = (((h * 3600 + mi * 60 + s) % 86400) + 86400) % 86400;
|
||||
return total / 86400;
|
||||
}
|
||||
|
||||
/** Serial -> UTC `Date`, for display formatting. */
|
||||
export function serialToUtcDate(serial: number): Date {
|
||||
return new Date(EPOCH_MS + Math.round(serial * MS_PER_DAY));
|
||||
}
|
||||
|
||||
/** A serial's calendar and clock fields. The time is rounded to the whole
|
||||
* second first, so floating-point noise (`TIME(14;30;0)` summed from parts)
|
||||
* never turns 14:30:00 into 14:29:59. `day` is the integer serial of the
|
||||
* calendar day; `weekday` follows ISO 8601 (Monday = 1 ... Sunday = 7). */
|
||||
export interface SerialParts {
|
||||
day: number;
|
||||
year: number;
|
||||
month: number;
|
||||
date: number;
|
||||
weekday: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
export function serialParts(serial: number): SerialParts {
|
||||
const totalSec = Math.round(serial * 86400);
|
||||
const day = Math.floor(totalSec / 86400);
|
||||
const sec = totalSec - day * 86400;
|
||||
const d = new Date(EPOCH_MS + day * MS_PER_DAY);
|
||||
return {
|
||||
day,
|
||||
year: d.getUTCFullYear(),
|
||||
month: d.getUTCMonth() + 1,
|
||||
date: d.getUTCDate(),
|
||||
weekday: ((d.getUTCDay() + 6) % 7) + 1,
|
||||
hour: Math.floor(sec / 3600),
|
||||
minute: Math.floor((sec % 3600) / 60),
|
||||
second: sec % 60,
|
||||
};
|
||||
}
|
||||
|
||||
/** ISO 8601 week number (weeks start Monday; week 1 is the one containing
|
||||
* the year's first Thursday) of a serial's calendar day. */
|
||||
export function isoWeekNumber(serial: number): number {
|
||||
const { day, weekday } = serialParts(serial);
|
||||
const thursday = day - weekday + 4; // the Thursday of this ISO week decides its year
|
||||
const { year } = serialParts(thursday);
|
||||
const jan1 = Math.round(dateToSerial(year, 1, 1));
|
||||
return Math.floor((thursday - jan1) / 7) + 1;
|
||||
}
|
||||
|
||||
/** Last calendar day of the month `months` after the serial's month. */
|
||||
export function endOfMonthSerial(serial: number, months: number): number {
|
||||
const { year, month } = serialParts(serial);
|
||||
return Math.round(dateToSerial(year, month + months + 1, 0));
|
||||
}
|
||||
|
||||
/** Same day `months` later, clamped to that month's last day (31.01. + 1
|
||||
* month = 28./29.02.), like Excel's EDATE. */
|
||||
export function addMonthsSerial(serial: number, months: number): number {
|
||||
const { year, month, date } = serialParts(serial);
|
||||
const last = serialParts(endOfMonthSerial(serial, months)).date;
|
||||
return Math.round(dateToSerial(year, month + months, Math.min(date, last)));
|
||||
}
|
||||
|
||||
/** Today's *local* calendar date (a date is a calendar concept, not a
|
||||
* timezone-relative instant), as an integer serial (no time-of-day part). */
|
||||
export function todaySerial(): number {
|
||||
const n = new Date();
|
||||
return Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate()));
|
||||
}
|
||||
|
||||
/** Local date + time, as a serial with a fractional part.
|
||||
*
|
||||
* Known limitation (documented in both READMEs): this engine recalculates
|
||||
* incrementally, only when a precedent cell changes -- there's no timer or
|
||||
* background work. A cell holding `=TODAY()`/`=NOW()` is NOT "volatile"
|
||||
* the way Excel's is: it refreshes the next time *that cell itself* is
|
||||
* re-edited or recalculated, not spontaneously when the clock ticks over. */
|
||||
export function nowSerial(): number {
|
||||
const n = new Date();
|
||||
return dateToSerial(
|
||||
n.getFullYear(),
|
||||
n.getMonth() + 1,
|
||||
n.getDate(),
|
||||
n.getHours(),
|
||||
n.getMinutes(),
|
||||
n.getSeconds() + n.getMilliseconds() / 1000,
|
||||
);
|
||||
}
|
||||
|
||||
/** `'duration'` is a time span typed with 24 or more hours (`37:30`) --
|
||||
* shown as elapsed hours instead of wrapping to a time of day. */
|
||||
export type DateLiteralKind = 'date' | 'time' | 'datetime' | 'duration';
|
||||
|
||||
const ISO_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/;
|
||||
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const DOT_DATETIME_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/;
|
||||
const DOT_DATE_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/;
|
||||
const TIME_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
|
||||
const DURATION_RE = /^(\d{1,6}):(\d{2})(?::(\d{2}))?$/;
|
||||
|
||||
/** 2-digit year -> 4-digit, the usual spreadsheet convention: 00-29 -> 20xx,
|
||||
* 30-99 -> 19xx. */
|
||||
function expandYear(yStr: string): number {
|
||||
if (yStr.length === 4) return Number(yStr);
|
||||
const yy = Number(yStr);
|
||||
return yy <= 29 ? 2000 + yy : 1900 + yy;
|
||||
}
|
||||
|
||||
/** Strict date builder for *recognizing typed input* -- unlike `dateToSerial`
|
||||
* (used by the `DATE()` formula function), an out-of-range or rolled-over
|
||||
* component (`31.04.2026`, `30.02.2026`, hour `25`) means "this text isn't
|
||||
* actually a date" rather than "roll over to the next month": leaving it as
|
||||
* plain text beats silently guessing. Builds the candidate via `Date.UTC`,
|
||||
* then verifies it by reading the fields back. */
|
||||
function strictDateSerial(y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number | null {
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) return null;
|
||||
const ms = Date.UTC(y, mo - 1, d, h, mi, s);
|
||||
const check = new Date(ms);
|
||||
if (check.getUTCFullYear() !== y || check.getUTCMonth() !== mo - 1 || check.getUTCDate() !== d) {
|
||||
return null;
|
||||
}
|
||||
return (ms - EPOCH_MS) / MS_PER_DAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize a raw cell literal as a date, time, or date+time -- used both to
|
||||
* turn typed input into its serial number ({@link workbook.ts}'s
|
||||
* `literalValue`) and to decide whether to auto-apply a matching display
|
||||
* format on entry (`store/document.ts`'s `setCellInput`).
|
||||
*
|
||||
* Deliberately locale-independent and conservative: only ISO dates
|
||||
* (`2026-09-02`) and dot-separated day.month.year dates (`02.09.2026`,
|
||||
* `02.09.26`) are recognized, plus `HH:MM[:SS]` time (24+ hours: a
|
||||
* duration) and date + time combined
|
||||
* with a space or `T`. Slash-separated dates (`02/09/2026`) are
|
||||
* intentionally NOT recognized: day-first vs. month-first is ambiguous
|
||||
* across this app's supported locales (en-US is month-first, every other
|
||||
* supported locale is day-first) -- guessing wrong would silently corrupt
|
||||
* the entered date, which is worse than leaving it as plain text.
|
||||
*/
|
||||
export function parseDateTimeLiteral(
|
||||
raw: string,
|
||||
): { kind: DateLiteralKind; serial: number } | null {
|
||||
const t = raw.trim();
|
||||
|
||||
let m = ISO_DATETIME_RE.exec(t);
|
||||
if (m) {
|
||||
const serial = strictDateSerial(+m[1], +m[2], +m[3], +m[4], +m[5], m[6] ? +m[6] : 0);
|
||||
return serial === null ? null : { kind: 'datetime', serial };
|
||||
}
|
||||
m = ISO_DATE_RE.exec(t);
|
||||
if (m) {
|
||||
const serial = strictDateSerial(+m[1], +m[2], +m[3]);
|
||||
return serial === null ? null : { kind: 'date', serial };
|
||||
}
|
||||
m = DOT_DATETIME_RE.exec(t);
|
||||
if (m) {
|
||||
const serial = strictDateSerial(expandYear(m[3]), +m[2], +m[1], +m[4], +m[5], m[6] ? +m[6] : 0);
|
||||
return serial === null ? null : { kind: 'datetime', serial };
|
||||
}
|
||||
m = DOT_DATE_RE.exec(t);
|
||||
if (m) {
|
||||
const serial = strictDateSerial(expandYear(m[3]), +m[2], +m[1]);
|
||||
return serial === null ? null : { kind: 'date', serial };
|
||||
}
|
||||
m = TIME_RE.exec(t);
|
||||
if (m) {
|
||||
const h = +m[1];
|
||||
const mi = +m[2];
|
||||
const s = m[3] ? +m[3] : 0;
|
||||
if (mi > 59 || s > 59) return null;
|
||||
if (h <= 23) return { kind: 'time', serial: timeSerial(h, mi, s) };
|
||||
}
|
||||
// 24 hours or more can't be a time of day -- it's an elapsed time
|
||||
m = DURATION_RE.exec(t);
|
||||
if (m) {
|
||||
const h = +m[1];
|
||||
const mi = +m[2];
|
||||
const s = m[3] ? +m[3] : 0;
|
||||
if (mi > 59 || s > 59) return null;
|
||||
return { kind: 'duration', serial: (h * 3600 + mi * 60 + s) / 86400 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Coerce a function argument to a number the way `evaluator.ts`'s
|
||||
* `toNumber()` does for arithmetic (null -> 0, numeric string cast,
|
||||
* anything else -> `#VALUE!`) -- duplicated here rather than imported
|
||||
* because `functions.ts` (and this module, which it uses) must not import
|
||||
* from `evaluator.ts` (evaluator.ts imports functions.ts, not the other
|
||||
* way around). Used by `DATE`/`TIME`'s argument handling in functions.ts. */
|
||||
export function strictNum(v: CellValue): number | FormulaError {
|
||||
if (isError(v)) return v;
|
||||
if (v === null) return 0;
|
||||
if (typeof v === 'number') return v;
|
||||
const trimmed = v.trim();
|
||||
if (trimmed === '') return 0;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Built-in functions: a plain table { NAME -> fn(values) }.
|
||||
*
|
||||
* By the time a function runs, the evaluator has already:
|
||||
* - resolved every range argument into a flat list of cell values,
|
||||
* - flattened scalar arguments into that same list,
|
||||
* - checked for errors among the values (an error short-circuits and is
|
||||
* returned instead of calling the function).
|
||||
*
|
||||
* So a function only ever sees `(number | string | null)[]`. Each function
|
||||
* decides for itself how to treat text and empty cells. Per spec:
|
||||
* - empty cell -> ignored by every aggregate, never counted by COUNT
|
||||
* - text -> ignored by the numeric aggregates
|
||||
*/
|
||||
|
||||
import { FormulaError, isError, type CellValue } from './types';
|
||||
import {
|
||||
dateToSerial,
|
||||
timeSerial,
|
||||
todaySerial,
|
||||
nowSerial,
|
||||
strictNum,
|
||||
serialParts,
|
||||
isoWeekNumber,
|
||||
endOfMonthSerial,
|
||||
addMonthsSerial,
|
||||
parseDateTimeLiteral,
|
||||
type SerialParts,
|
||||
} from './dates';
|
||||
|
||||
export type FunctionFn = (values: CellValue[]) => CellValue;
|
||||
|
||||
/** Keep only real numbers (finite). */
|
||||
function nums(values: CellValue[]): number[] {
|
||||
const out: number[] = [];
|
||||
for (const v of values) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The one truthy convention used everywhere in this engine a condition is
|
||||
* needed (IF's condition -- both the formula function and its real
|
||||
* short-circuit implementation in evaluator.ts's evalIf() --, a "Mit
|
||||
* Schleife" block's IF statement, and AND/OR/NOT below): a number is
|
||||
* truthy unless it's exactly `0`; text is truthy unless empty; an empty
|
||||
* cell (`null`) is never truthy. Exported so evaluator.ts can reuse the
|
||||
* exact same rule instead of duplicating it. */
|
||||
export function isTruthy(v: CellValue): boolean {
|
||||
return typeof v === 'number' ? v !== 0 : v !== null && v !== '';
|
||||
}
|
||||
|
||||
/** Check the argument count and coerce every argument to a number (see
|
||||
* `strictNum`) -- the shared prologue of the numeric date functions. An
|
||||
* optional argument left out (or an empty `;;` slot) comes back as
|
||||
* `undefined`. */
|
||||
function numArgs(
|
||||
name: string,
|
||||
values: CellValue[],
|
||||
min: number,
|
||||
max: number,
|
||||
): [number, ...(number | undefined)[]] | FormulaError {
|
||||
if (values.length < min || values.length > max) {
|
||||
const range = min === max ? `${min}` : `${min}–${max}`;
|
||||
return new FormulaError('#ERR', `${name} erwartet ${range} Argument${max === 1 ? '' : 'e'}`);
|
||||
}
|
||||
const out: Array<number | undefined> = [];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (i >= min && values[i] === null) {
|
||||
out.push(undefined);
|
||||
continue;
|
||||
}
|
||||
const n = strictNum(values[i]);
|
||||
if (isError(n)) return n;
|
||||
out.push(n);
|
||||
}
|
||||
return out as [number, ...(number | undefined)[]];
|
||||
}
|
||||
|
||||
/** YEAR/MONTH/DAY/HOUR/MINUTE/SECOND: one serial in, one field out. */
|
||||
function part(name: string, pick: (p: SerialParts) => number): FunctionFn {
|
||||
return (values) => {
|
||||
const a = numArgs(name, values, 1, 1);
|
||||
if (isError(a)) return a;
|
||||
return pick(serialParts(a[0]));
|
||||
};
|
||||
}
|
||||
|
||||
function holidaySet(values: CellValue[]): Set<number> {
|
||||
const out = new Set<number>();
|
||||
for (const v of values) if (typeof v === 'number') out.add(serialParts(v).day);
|
||||
return out;
|
||||
}
|
||||
|
||||
function isWorkday(day: number, holidays: Set<number>): boolean {
|
||||
return serialParts(day).weekday <= 5 && !holidays.has(day);
|
||||
}
|
||||
|
||||
/** DATEVALUE/TIMEVALUE's single argument: a recognizable date/time text, or
|
||||
* a number (already a serial). */
|
||||
function textDateArg(name: string, values: CellValue[]): number | FormulaError {
|
||||
if (values.length !== 1) return new FormulaError('#ERR', `${name} erwartet 1 Argument`);
|
||||
const v = values[0];
|
||||
if (typeof v === 'number') return v;
|
||||
const hit = typeof v === 'string' ? parseDateTimeLiteral(v) : null;
|
||||
if (!hit) return new FormulaError('#VALUE!', `${name}: '${v ?? ''}' ist kein Datum/keine Uhrzeit`);
|
||||
return hit.serial;
|
||||
}
|
||||
|
||||
export const FUNCTIONS: Record<string, FunctionFn> = {
|
||||
SUM(values) {
|
||||
return nums(values).reduce((a, b) => a + b, 0);
|
||||
},
|
||||
|
||||
AVG(values) {
|
||||
const n = nums(values);
|
||||
if (n.length === 0) return new FormulaError('#DIV/0!', 'AVG of no numbers');
|
||||
return n.reduce((a, b) => a + b, 0) / n.length;
|
||||
},
|
||||
|
||||
MIN(values) {
|
||||
const n = nums(values);
|
||||
return n.length ? Math.min(...n) : 0;
|
||||
},
|
||||
|
||||
MAX(values) {
|
||||
const n = nums(values);
|
||||
return n.length ? Math.max(...n) : 0;
|
||||
},
|
||||
|
||||
COUNT(values) {
|
||||
return nums(values).length;
|
||||
},
|
||||
|
||||
COUNT2(values) {
|
||||
return values.length;
|
||||
},
|
||||
|
||||
// Plain text join, no separator (unlike Excel's TEXTJOIN). Same rendering
|
||||
// as the `&` operator (toText() in evaluator.ts): a number prints plain
|
||||
// (no locale formatting), an empty cell contributes ''.
|
||||
CONCAT(values) {
|
||||
return values.map((v) => (v === null ? '' : String(v))).join('');
|
||||
},
|
||||
|
||||
// IF(cond; then; else?) -- evaluator.ts special-cases this name *before*
|
||||
// ever reaching FUNCTIONS, giving it real short-circuit evaluation (only
|
||||
// the taken branch is computed, so e.g. IF(B1=0; 0; A1/B1) never runs the
|
||||
// division when B1=0). This entry only exists so IF is a registered
|
||||
// built-in for hasFunction()'s sake (the store's custom-function name
|
||||
// check then correctly refuses to let a user shadow it) and as a
|
||||
// defensive, best-effort fallback if this were ever reached directly
|
||||
// (values are already-eagerly-computed here, so it can't short-circuit --
|
||||
// see evaluator.ts's evalIf() for the real implementation).
|
||||
IF(values) {
|
||||
if (values.length !== 2 && values.length !== 3) {
|
||||
return new FormulaError('#ERR', 'IF expects 2 or 3 arguments');
|
||||
}
|
||||
const [cond, thenValue, elseValue] = values;
|
||||
if (isError(cond)) return cond;
|
||||
if (isTruthy(cond)) return thenValue;
|
||||
return values.length === 3 ? elseValue : 0;
|
||||
},
|
||||
|
||||
// Ordinary (non-short-circuit) functions -- like real Excel, EVERY
|
||||
// argument is evaluated regardless of the others (so e.g.
|
||||
// =AND(1=2; 1/0) is still #DIV/0!, not FALSE, exactly as in Excel) --
|
||||
// no special-casing in evaluator.ts needed, unlike IF/XLOOKUP. A range
|
||||
// argument flattens into one truthy check per cell, same as Excel.
|
||||
AND(values) {
|
||||
if (values.length === 0) return new FormulaError('#ERR', 'AND expects at least 1 argument');
|
||||
return values.every(isTruthy) ? 1 : 0;
|
||||
},
|
||||
|
||||
OR(values) {
|
||||
if (values.length === 0) return new FormulaError('#ERR', 'OR expects at least 1 argument');
|
||||
return values.some(isTruthy) ? 1 : 0;
|
||||
},
|
||||
|
||||
NOT(values) {
|
||||
if (values.length !== 1) return new FormulaError('#ERR', 'NOT expects exactly 1 argument');
|
||||
return isTruthy(values[0]) ? 0 : 1;
|
||||
},
|
||||
|
||||
// Dates/times are plain numbers here (see dates.ts) -- DATE/TIME build one,
|
||||
// TODAY/NOW read the current one. `null` (empty cell) arguments count as 0,
|
||||
// same as everywhere else in this engine; a non-numeric text argument is
|
||||
// `#VALUE!`.
|
||||
DATE(values) {
|
||||
if (values.length !== 3) {
|
||||
return new FormulaError('#ERR', 'DATE expects 3 arguments (Jahr; Monat; Tag)');
|
||||
}
|
||||
const y = strictNum(values[0]);
|
||||
if (isError(y)) return y;
|
||||
const mo = strictNum(values[1]);
|
||||
if (isError(mo)) return mo;
|
||||
const d = strictNum(values[2]);
|
||||
if (isError(d)) return d;
|
||||
return dateToSerial(y, mo, d);
|
||||
},
|
||||
|
||||
TIME(values) {
|
||||
if (values.length !== 3) {
|
||||
return new FormulaError('#ERR', 'TIME expects 3 arguments (Stunde; Minute; Sekunde)');
|
||||
}
|
||||
const h = strictNum(values[0]);
|
||||
if (isError(h)) return h;
|
||||
const mi = strictNum(values[1]);
|
||||
if (isError(mi)) return mi;
|
||||
const s = strictNum(values[2]);
|
||||
if (isError(s)) return s;
|
||||
return timeSerial(h, mi, s);
|
||||
},
|
||||
|
||||
// Not "volatile": this engine only recalculates on precedent change, so a
|
||||
// cell holding =TODAY()/=NOW() refreshes when THAT cell is next re-edited
|
||||
// or recalculated, not spontaneously when the clock ticks over (documented
|
||||
// limitation, see README.md's "Bekannte Grenzen").
|
||||
TODAY(values) {
|
||||
if (values.length !== 0) return new FormulaError('#ERR', 'TODAY erwartet keine Argumente');
|
||||
return todaySerial();
|
||||
},
|
||||
|
||||
NOW(values) {
|
||||
if (values.length !== 0) return new FormulaError('#ERR', 'NOW erwartet keine Argumente');
|
||||
return nowSerial();
|
||||
},
|
||||
|
||||
// ---- date/time parts & calendar arithmetic ----------------------------
|
||||
// All take date serials (see dates.ts). Where Excel has a US-centric
|
||||
// default, the ISO 8601 convention is the default here: weeks start on
|
||||
// Monday (WEEKDAY -> 1 = Monday), week numbers are ISO weeks.
|
||||
YEAR: part('YEAR', (p) => p.year),
|
||||
MONTH: part('MONTH', (p) => p.month),
|
||||
DAY: part('DAY', (p) => p.date),
|
||||
HOUR: part('HOUR', (p) => p.hour),
|
||||
MINUTE: part('MINUTE', (p) => p.minute),
|
||||
SECOND: part('SECOND', (p) => p.second),
|
||||
|
||||
// WEEKDAY(Datum; [Typ]) -- Typ as in Excel: 1 = Sunday 1 ... Saturday 7,
|
||||
// 2 = Monday 1 ... Sunday 7 (ISO, the default here), 3 = Monday 0 ...
|
||||
// Sunday 6.
|
||||
WEEKDAY(values) {
|
||||
const a = numArgs('WEEKDAY', values, 1, 2);
|
||||
if (isError(a)) return a;
|
||||
const iso = serialParts(a[0]).weekday;
|
||||
switch (a[1] ?? 2) {
|
||||
case 1:
|
||||
return (iso % 7) + 1;
|
||||
case 2:
|
||||
return iso;
|
||||
case 3:
|
||||
return iso - 1;
|
||||
default:
|
||||
return new FormulaError('#VALUE!', 'WEEKDAY: Typ muss 1, 2 oder 3 sein');
|
||||
}
|
||||
},
|
||||
|
||||
ISOWEEKNUM(values) {
|
||||
const a = numArgs('ISOWEEKNUM', values, 1, 1);
|
||||
if (isError(a)) return a;
|
||||
return isoWeekNumber(a[0]);
|
||||
},
|
||||
|
||||
// WEEKNUM(Datum; [Typ]) -- ISO week by default (Typ 21, as in Excel);
|
||||
// Typ 1 = weeks start Sunday, 2 = weeks start Monday, both counting the
|
||||
// week containing 1 January as week 1 (Excel's US-style numbering).
|
||||
WEEKNUM(values) {
|
||||
const a = numArgs('WEEKNUM', values, 1, 2);
|
||||
if (isError(a)) return a;
|
||||
const type = a[1] ?? 21;
|
||||
if (type === 21) return isoWeekNumber(a[0]);
|
||||
if (type !== 1 && type !== 2) return new FormulaError('#VALUE!', 'WEEKNUM: Typ muss 1, 2 oder 21 sein');
|
||||
const p = serialParts(a[0]);
|
||||
const jan1 = Math.round(dateToSerial(p.year, 1, 1));
|
||||
const jan1Iso = serialParts(jan1).weekday; // 1 = Monday
|
||||
const offset = type === 2 ? jan1Iso - 1 : jan1Iso % 7; // days since week start
|
||||
return Math.floor((p.day - jan1 + offset) / 7) + 1;
|
||||
},
|
||||
|
||||
EDATE(values) {
|
||||
const a = numArgs('EDATE', values, 2, 2);
|
||||
if (isError(a)) return a;
|
||||
return addMonthsSerial(a[0], Math.trunc(a[1] ?? 0));
|
||||
},
|
||||
|
||||
EOMONTH(values) {
|
||||
const a = numArgs('EOMONTH', values, 2, 2);
|
||||
if (isError(a)) return a;
|
||||
return endOfMonthSerial(a[0], Math.trunc(a[1] ?? 0));
|
||||
},
|
||||
|
||||
// DAYS(Ende; Start) -- whole calendar days between two dates.
|
||||
DAYS(values) {
|
||||
const a = numArgs('DAYS', values, 2, 2);
|
||||
if (isError(a)) return a;
|
||||
return serialParts(a[0]).day - serialParts(a[1] ?? 0).day;
|
||||
},
|
||||
|
||||
// DATEDIF(Start; Ende; Einheit) -- "Y", "M", "D" complete years/months/
|
||||
// days, "MD"/"YM"/"YD" the remainder ignoring months+years/years.
|
||||
DATEDIF(values) {
|
||||
if (values.length !== 3) {
|
||||
return new FormulaError('#ERR', 'DATEDIF erwartet 3 Argumente (Start; Ende; Einheit)');
|
||||
}
|
||||
const s0 = strictNum(values[0]);
|
||||
if (isError(s0)) return s0;
|
||||
const e0 = strictNum(values[1]);
|
||||
if (isError(e0)) return e0;
|
||||
const unit = typeof values[2] === 'string' ? values[2].trim().toUpperCase() : '';
|
||||
const s = serialParts(s0);
|
||||
const e = serialParts(e0);
|
||||
if (s.day > e.day) return new FormulaError('#VALUE!', 'DATEDIF: Start liegt nach Ende');
|
||||
let months = (e.year - s.year) * 12 + (e.month - s.month);
|
||||
if (e.date < s.date) months -= 1;
|
||||
switch (unit) {
|
||||
case 'D':
|
||||
return e.day - s.day;
|
||||
case 'M':
|
||||
return months;
|
||||
case 'Y':
|
||||
return Math.floor(months / 12);
|
||||
case 'YM':
|
||||
return months % 12;
|
||||
case 'MD':
|
||||
return e.day - addMonthsSerial(s.day, months);
|
||||
case 'YD': {
|
||||
const years = Math.floor(months / 12);
|
||||
return e.day - addMonthsSerial(s.day, years * 12);
|
||||
}
|
||||
default:
|
||||
return new FormulaError('#VALUE!', 'DATEDIF: Einheit muss "Y", "M", "D", "MD", "YM" oder "YD" sein');
|
||||
}
|
||||
},
|
||||
|
||||
// NETWORKDAYS(Start; Ende; [Feiertage…]) -- Monday-Friday days in the
|
||||
// span, both ends included, minus any listed holiday on a weekday.
|
||||
NETWORKDAYS(values) {
|
||||
if (values.length < 2) {
|
||||
return new FormulaError('#ERR', 'NETWORKDAYS erwartet mindestens 2 Argumente (Start; Ende; [Feiertage])');
|
||||
}
|
||||
const s0 = strictNum(values[0]);
|
||||
if (isError(s0)) return s0;
|
||||
const e0 = strictNum(values[1]);
|
||||
if (isError(e0)) return e0;
|
||||
const holidays = holidaySet(values.slice(2));
|
||||
const a = serialParts(s0).day;
|
||||
const b = serialParts(e0).day;
|
||||
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
||||
if (hi - lo > 366 * 1000) return new FormulaError('#VALUE!', 'NETWORKDAYS: Zeitraum zu groß');
|
||||
let n = 0;
|
||||
for (let d = lo; d <= hi; d++) if (isWorkday(d, holidays)) n++;
|
||||
return a <= b ? n : -n;
|
||||
},
|
||||
|
||||
// WORKDAY(Start; Tage; [Feiertage…]) -- the date `Tage` working days
|
||||
// (Monday-Friday, not a holiday) after (or, negative, before) Start.
|
||||
WORKDAY(values) {
|
||||
if (values.length < 2) {
|
||||
return new FormulaError('#ERR', 'WORKDAY erwartet mindestens 2 Argumente (Start; Tage; [Feiertage])');
|
||||
}
|
||||
const s0 = strictNum(values[0]);
|
||||
if (isError(s0)) return s0;
|
||||
const n0 = strictNum(values[1]);
|
||||
if (isError(n0)) return n0;
|
||||
let left = Math.trunc(Math.abs(n0));
|
||||
if (left > 366 * 1000) return new FormulaError('#VALUE!', 'WORKDAY: zu viele Tage');
|
||||
const step = n0 < 0 ? -1 : 1;
|
||||
const holidays = holidaySet(values.slice(2));
|
||||
let d = serialParts(s0).day;
|
||||
while (left > 0) {
|
||||
d += step;
|
||||
if (isWorkday(d, holidays)) left--;
|
||||
}
|
||||
return d;
|
||||
},
|
||||
|
||||
// DATEVALUE("2026-09-02") / TIMEVALUE("14:30") -- the date resp. time part
|
||||
// of a text in any format a cell entry is recognized in (ISO, 02.09.2026,
|
||||
// 14:30, …). A number (a cell already holding a date) passes through.
|
||||
DATEVALUE(values) {
|
||||
const v = textDateArg('DATEVALUE', values);
|
||||
if (isError(v)) return v;
|
||||
return serialParts(v).day;
|
||||
},
|
||||
|
||||
TIMEVALUE(values) {
|
||||
const v = textDateArg('TIMEVALUE', values);
|
||||
if (isError(v)) return v;
|
||||
const p = serialParts(v);
|
||||
return (p.hour * 3600 + p.minute * 60 + p.second) / 86400;
|
||||
},
|
||||
|
||||
// XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden];
|
||||
// [Vergleichsmodus]; [Suchmodus]) -- like IF, evaluator.ts special-cases
|
||||
// this name *before* ever reaching FUNCTIONS. Two reasons, not just one:
|
||||
// 1. WennNichtGefunden must only be evaluated when actually needed
|
||||
// (short-circuit, same reasoning as IF's branches).
|
||||
// 2. By the time a function gets here, EVERY argument -- including
|
||||
// Suchbereich and Rückgabebereich, both ranges -- has already been
|
||||
// flattened into ONE combined array (see the module comment above).
|
||||
// That destroys the boundary between the two ranges, so there is no
|
||||
// way to even attempt a best-effort fallback here: unlike IF's
|
||||
// 2-3 scalar arguments, XLOOKUP's array arguments can't be recovered
|
||||
// from a flat `values` list of unknown split point. This entry exists
|
||||
// purely so XLOOKUP is a registered built-in (hasFunction() /
|
||||
// name-reservation) -- see evaluator.ts's evalXLookup() for the real
|
||||
// implementation. Reaching this in practice would be an engine bug.
|
||||
XLOOKUP() {
|
||||
return new FormulaError('#ERR', 'XLOOKUP must be evaluated by evaluator.ts, not called directly');
|
||||
},
|
||||
};
|
||||
|
||||
// AVERAGE is an alias of AVG.
|
||||
FUNCTIONS.AVERAGE = FUNCTIONS.AVG;
|
||||
// CONCATENATE is Excel's classic name for the same thing as CONCAT.
|
||||
FUNCTIONS.CONCATENATE = FUNCTIONS.CONCAT;
|
||||
|
||||
/** One-line signature hint per built-in, for the formula-autocomplete
|
||||
* dropdown (see store/document.ts's `functionSuggestions()` and
|
||||
* src/composables/useFormulaAutocomplete.ts). Hand-maintained -- kept
|
||||
* honest by a test asserting its names exactly match `FUNCTIONS`'s keys,
|
||||
* so a future new built-in without a matching entry here fails loudly
|
||||
* instead of just silently missing from the dropdown. */
|
||||
export const BUILTIN_SIGNATURES: ReadonlyArray<{ name: string; hint: string }> = [
|
||||
{ name: 'SUM', hint: 'SUM(Zahl1; Zahl2; …)' },
|
||||
{ name: 'AVG', hint: 'AVG(Zahl1; Zahl2; …)' },
|
||||
{ name: 'AVERAGE', hint: 'AVERAGE(Zahl1; Zahl2; …)' },
|
||||
{ name: 'MIN', hint: 'MIN(Zahl1; Zahl2; …)' },
|
||||
{ name: 'MAX', hint: 'MAX(Zahl1; Zahl2; …)' },
|
||||
{ name: 'COUNT', hint: 'COUNT(Zahl1; Zahl2; …)' },
|
||||
{ name: 'COUNT2', hint: 'COUNT2(Text1; Zahl2; …)' },
|
||||
{ name: 'CONCAT', hint: 'CONCAT(Text1; Text2; …)' },
|
||||
{ name: 'CONCATENATE', hint: 'CONCATENATE(Text1; Text2; …)' },
|
||||
{ name: 'IF', hint: 'IF(Bedingung; Dann; Sonst?)' },
|
||||
{ name: 'AND', hint: 'AND(Bedingung1; Bedingung2; …)' },
|
||||
{ name: 'OR', hint: 'OR(Bedingung1; Bedingung2; …)' },
|
||||
{ name: 'NOT', hint: 'NOT(Bedingung)' },
|
||||
{ name: 'DATE', hint: 'DATE(Jahr; Monat; Tag)' },
|
||||
{ name: 'TIME', hint: 'TIME(Std; Min; Sek)' },
|
||||
{ name: 'TODAY', hint: 'TODAY()' },
|
||||
{ name: 'NOW', hint: 'NOW()' },
|
||||
{ name: 'YEAR', hint: 'YEAR(Datum)' },
|
||||
{ name: 'MONTH', hint: 'MONTH(Datum)' },
|
||||
{ name: 'DAY', hint: 'DAY(Datum)' },
|
||||
{ name: 'HOUR', hint: 'HOUR(Zeit)' },
|
||||
{ name: 'MINUTE', hint: 'MINUTE(Zeit)' },
|
||||
{ name: 'SECOND', hint: 'SECOND(Zeit)' },
|
||||
{ name: 'WEEKDAY', hint: 'WEEKDAY(Datum; Typ?)' },
|
||||
{ name: 'ISOWEEKNUM', hint: 'ISOWEEKNUM(Datum)' },
|
||||
{ name: 'WEEKNUM', hint: 'WEEKNUM(Datum; Typ?)' },
|
||||
{ name: 'EDATE', hint: 'EDATE(Datum; Monate)' },
|
||||
{ name: 'EOMONTH', hint: 'EOMONTH(Datum; Monate)' },
|
||||
{ name: 'DAYS', hint: 'DAYS(Ende; Start)' },
|
||||
{ name: 'DATEDIF', hint: 'DATEDIF(Start; Ende; Einheit)' },
|
||||
{ name: 'NETWORKDAYS', hint: 'NETWORKDAYS(Start; Ende; Feiertage?)' },
|
||||
{ name: 'WORKDAY', hint: 'WORKDAY(Start; Tage; Feiertage?)' },
|
||||
{ name: 'DATEVALUE', hint: 'DATEVALUE(Text)' },
|
||||
{ name: 'TIMEVALUE', hint: 'TIMEVALUE(Text)' },
|
||||
{ name: 'XLOOKUP', hint: 'XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; …)' },
|
||||
];
|
||||
|
||||
export function hasFunction(name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(FUNCTIONS, name.toUpperCase());
|
||||
}
|
||||
|
||||
export function getFunction(name: string): FunctionFn | undefined {
|
||||
return FUNCTIONS[name.toUpperCase()];
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Dependency graph over *all* sheets.
|
||||
*
|
||||
* Nodes are cell keys (see `keyOf` in ./types). An edge P -> C means
|
||||
* "cell C reads cell P" (P is a precedent of C, C is a dependent of P).
|
||||
*
|
||||
* The graph offers two things the recalculation driver needs:
|
||||
* 1. `affected(dirty)` -- every cell that must be revisited after `dirty`
|
||||
* changed (the dirty cells plus their transitive dependents).
|
||||
* 2. `topoOrder(nodes)` -- a safe evaluation order for a set of nodes, plus
|
||||
* the subset that sits on a dependency cycle (`#CIRC`).
|
||||
*
|
||||
* Circular references -- including cross-sheet ones -- are found by an iterative
|
||||
* DFS with white/grey/black colouring. When a back-edge to a grey node is seen,
|
||||
* every node currently on the DFS stack from that node onwards is flagged
|
||||
* circular. Flagged nodes are dropped from the returned order; the driver writes
|
||||
* `#CIRC` into them directly and their dependents pick that up through normal
|
||||
* error propagation.
|
||||
*/
|
||||
|
||||
export class DependencyGraph {
|
||||
/** precedents: key -> set of cells it reads */
|
||||
private readonly precedents = new Map<string, Set<string>>();
|
||||
/** dependents: key -> set of cells that read it */
|
||||
private readonly dependents = new Map<string, Set<string>>();
|
||||
|
||||
/** Replace the precedent set of `cell` (called after (re)parsing a formula). */
|
||||
setPrecedents(cell: string, precedents: Iterable<string>): void {
|
||||
// unlink old edges
|
||||
const old = this.precedents.get(cell);
|
||||
if (old) {
|
||||
for (const p of old) this.dependents.get(p)?.delete(cell);
|
||||
}
|
||||
// Keep a self-reference if present: `=A1` inside A1 must surface as #CIRC.
|
||||
const next = new Set(precedents);
|
||||
this.precedents.set(cell, next);
|
||||
for (const p of next) {
|
||||
let d = this.dependents.get(p);
|
||||
if (!d) this.dependents.set(p, (d = new Set()));
|
||||
d.add(cell);
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget a cell entirely (e.g. it became a literal or was cleared). */
|
||||
clear(cell: string): void {
|
||||
const old = this.precedents.get(cell);
|
||||
if (old) for (const p of old) this.dependents.get(p)?.delete(cell);
|
||||
this.precedents.delete(cell);
|
||||
// keep `dependents.get(cell)` -- other cells may still point here (=> #REF/0),
|
||||
// that entry is harmless and gets cleaned as those cells are re-parsed.
|
||||
}
|
||||
|
||||
/** Drop every edge (used on structural rebuilds). */
|
||||
reset(): void {
|
||||
this.precedents.clear();
|
||||
this.dependents.clear();
|
||||
}
|
||||
|
||||
getPrecedents(cell: string): Set<string> {
|
||||
return this.precedents.get(cell) ?? new Set();
|
||||
}
|
||||
getDependents(cell: string): Set<string> {
|
||||
return this.dependents.get(cell) ?? new Set();
|
||||
}
|
||||
|
||||
/** Dirty cells + all their transitive dependents. */
|
||||
affected(dirty: Iterable<string>): Set<string> {
|
||||
const seen = new Set<string>();
|
||||
const stack = [...dirty];
|
||||
while (stack.length) {
|
||||
const k = stack.pop()!;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
const deps = this.dependents.get(k);
|
||||
if (deps) for (const d of deps) if (!seen.has(d)) stack.push(d);
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topologically sort `nodes` using precedent edges that stay inside `nodes`.
|
||||
* Returns the evaluation order (precedents first) and the set of nodes that
|
||||
* lie on a cycle.
|
||||
*/
|
||||
topoOrder(nodes: Set<string>): { order: string[]; circular: Set<string> } {
|
||||
const WHITE = 0;
|
||||
const GREY = 1;
|
||||
const BLACK = 2;
|
||||
const color = new Map<string, number>();
|
||||
const order: string[] = [];
|
||||
const circular = new Set<string>();
|
||||
for (const n of nodes) color.set(n, WHITE);
|
||||
|
||||
for (const root of nodes) {
|
||||
if (color.get(root) !== WHITE) continue;
|
||||
|
||||
// iterative DFS; frame = { node, iterator over its in-scope precedents }
|
||||
const stack: Array<{ node: string; iter: Iterator<string> }> = [];
|
||||
const pathIndex = new Map<string, number>(); // node -> position on stack
|
||||
|
||||
const push = (node: string) => {
|
||||
color.set(node, GREY);
|
||||
pathIndex.set(node, stack.length);
|
||||
const precs = [...this.getPrecedents(node)].filter((p) => nodes.has(p));
|
||||
stack.push({ node, iter: precs[Symbol.iterator]() });
|
||||
};
|
||||
push(root);
|
||||
|
||||
while (stack.length) {
|
||||
const frame = stack[stack.length - 1];
|
||||
const step = frame.iter.next();
|
||||
if (step.done) {
|
||||
color.set(frame.node, BLACK);
|
||||
pathIndex.delete(frame.node);
|
||||
order.push(frame.node);
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const p = step.value;
|
||||
const c = color.get(p);
|
||||
if (c === WHITE) {
|
||||
push(p);
|
||||
} else if (c === GREY) {
|
||||
// back-edge -> cycle. Flag everything from p to the current frame.
|
||||
const from = pathIndex.get(p)!;
|
||||
for (let k = from; k < stack.length; k++) circular.add(stack[k].node);
|
||||
}
|
||||
// BLACK -> already settled, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// A node that depends on a circular node is not itself circular, but it must
|
||||
// be evaluated *after* the driver writes #CIRC. Keeping it in `order` is
|
||||
// fine; drop only the truly circular nodes.
|
||||
return { order: order.filter((n) => !circular.has(n)), circular };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Public surface of the formula engine.
|
||||
*
|
||||
* Nothing in `./` imports Vue, the DOM or any spreadsheet library. See
|
||||
* ./README.md for the grammar (EBNF), the evaluation model and the error model.
|
||||
*/
|
||||
|
||||
export { tokenize, LexError, type Token, type TokenType } from './lexer';
|
||||
export {
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
parseMacroBlock,
|
||||
collectRefs,
|
||||
collectRefsInBlock,
|
||||
ParseError,
|
||||
type Node,
|
||||
type CellNode,
|
||||
type RangeNode,
|
||||
type NamedRefNode,
|
||||
type BlockNode,
|
||||
type ActionNode,
|
||||
} from './parser';
|
||||
export { evaluate, runMacroActions } from './evaluator';
|
||||
export { DependencyGraph } from './graph';
|
||||
export { FUNCTIONS, getFunction, hasFunction, BUILTIN_SIGNATURES } from './functions';
|
||||
export {
|
||||
colToNumber,
|
||||
numberToCol,
|
||||
parseA1,
|
||||
toA1,
|
||||
looksLikeCell,
|
||||
expandRange,
|
||||
rangeCells,
|
||||
wholeLineAt,
|
||||
parseLinePart,
|
||||
formatLinePart,
|
||||
WHOLE_LINE_END,
|
||||
cycleReferenceLocks,
|
||||
currentIdentifierAtCaret,
|
||||
} from './refs';
|
||||
export {
|
||||
Workbook,
|
||||
literalValue,
|
||||
type TableModel,
|
||||
} from './workbook';
|
||||
export {
|
||||
dateToSerial,
|
||||
timeSerial,
|
||||
serialToUtcDate,
|
||||
todaySerial,
|
||||
nowSerial,
|
||||
parseDateTimeLiteral,
|
||||
type DateLiteralKind,
|
||||
} from './dates';
|
||||
export {
|
||||
buildFunctionIndex,
|
||||
expandUserFunctions,
|
||||
expandMacroBlock,
|
||||
collectCalledFunctionNames,
|
||||
collectCalledFunctionNamesInBlock,
|
||||
declaredBlockNames,
|
||||
findInvalidForSource,
|
||||
findInvalidActionTarget,
|
||||
blockAlwaysReturns,
|
||||
type FunctionDef,
|
||||
} from './userFunctions';
|
||||
export {
|
||||
FormulaError,
|
||||
isError,
|
||||
keyOf,
|
||||
parseKey,
|
||||
ERROR_CODES,
|
||||
type ErrorCode,
|
||||
type CellValue,
|
||||
type CellRef,
|
||||
type Scalar,
|
||||
type EngineHost,
|
||||
type MacroWriteHost,
|
||||
type MacroNetworkHost,
|
||||
type NamedRangeDef,
|
||||
} from './types';
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Tokenizer for the formula language.
|
||||
*
|
||||
* The lexer is deliberately dumb: it splits the input into a flat token stream
|
||||
* and leaves *all* structural decisions to the parser. In particular it does not
|
||||
* try to tell a cell reference ("A1") from a function name ("SUM") or a sheet
|
||||
* name ("Umsatz") -- they are all `IDENT` tokens and the parser disambiguates
|
||||
* them by look-ahead (`(` -> function, `!` -> sheet qualifier, digits -> cell).
|
||||
*/
|
||||
|
||||
import { ERROR_CODES } from './types';
|
||||
|
||||
export type TokenType =
|
||||
| 'NUMBER'
|
||||
| 'STRING'
|
||||
| 'IDENT' // bare word: A1, SUM, Region, Q1 ...
|
||||
| 'QUOTED' // 'Kosten Q3' -- a sheet name in single quotes
|
||||
| 'ERRORLIT' // a literal error code, e.g. "#REF" or "#DIV/0!" -- see ERROR_CODES
|
||||
| 'PLUS'
|
||||
| 'MINUS'
|
||||
| 'STAR'
|
||||
| 'SLASH'
|
||||
| 'CARET'
|
||||
| 'AMP' // "&" -- text concatenation
|
||||
| 'LPAREN'
|
||||
| 'RPAREN'
|
||||
| 'SEMICOLON'
|
||||
| 'COLON'
|
||||
| 'DBLCOLON' // "::" -- Numbers-style table qualifier ('Tabelle'::A1)
|
||||
| 'BANG'
|
||||
| 'EQUALS' // "=" -- assignment (block body) or equality comparison
|
||||
| 'NEQ' // "<>"
|
||||
| 'LT' // "<"
|
||||
| 'GT' // ">"
|
||||
| 'LTE' // "<="
|
||||
| 'GTE' // ">="
|
||||
| 'EOF';
|
||||
|
||||
export interface Token {
|
||||
type: TokenType;
|
||||
/** Raw text of the token (unquoted for STRING/QUOTED). */
|
||||
value: string;
|
||||
/** Character offset in the source, useful for error messages. */
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export class LexError extends Error {}
|
||||
|
||||
const SINGLE: Record<string, TokenType> = {
|
||||
'+': 'PLUS',
|
||||
'-': 'MINUS',
|
||||
'*': 'STAR',
|
||||
'/': 'SLASH',
|
||||
'^': 'CARET',
|
||||
'&': 'AMP',
|
||||
'(': 'LPAREN',
|
||||
')': 'RPAREN',
|
||||
';': 'SEMICOLON',
|
||||
':': 'COLON',
|
||||
'!': 'BANG',
|
||||
'=': 'EQUALS',
|
||||
'<': 'LT',
|
||||
'>': 'GT',
|
||||
};
|
||||
|
||||
function isDigit(ch: string): boolean {
|
||||
return ch >= '0' && ch <= '9';
|
||||
}
|
||||
/** Any Unicode letter, not just ASCII -- a table/sheet name (used unquoted
|
||||
* as a formula qualifier whenever it has no spaces) routinely contains
|
||||
* "ä"/"ö"/"ü"/"ß" or other accented letters in this German-first app;
|
||||
* rejecting those at the lexer level made every such name effectively
|
||||
* unusable as a bare qualifier (`Übersicht::A1` used to throw outright). */
|
||||
const IS_LETTER = /\p{L}/u;
|
||||
function isWordStart(ch: string): boolean {
|
||||
// '$' only ever appears inside a cell reference ("$A1", "A$1", "$A$1"), but
|
||||
// the lexer doesn't distinguish those from any other bare word (see the
|
||||
// module doc) -- it's just part of the word here, and the parser is the one
|
||||
// that validates/interprets it as a locked reference axis.
|
||||
return IS_LETTER.test(ch) || ch === '_' || ch === '$';
|
||||
}
|
||||
function isWordPart(ch: string): boolean {
|
||||
return isWordStart(ch) || isDigit(ch);
|
||||
}
|
||||
|
||||
/** Longest-first, so a prefix of another code (none collide today, but this
|
||||
* stays correct if one ever did) never wins the match by accident. */
|
||||
const ERROR_CODES_BY_LENGTH = [...ERROR_CODES].sort((a, b) => b.length - a.length);
|
||||
|
||||
export function tokenize(src: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
|
||||
while (i < n) {
|
||||
const ch = src[i];
|
||||
|
||||
// whitespace
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// number: 12 12.5 .5 1e3 1.2E-3
|
||||
if (isDigit(ch) || (ch === '.' && isDigit(src[i + 1]))) {
|
||||
const start = i;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
if (src[i] === '.') {
|
||||
i++;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
}
|
||||
if (src[i] === 'e' || src[i] === 'E') {
|
||||
let j = i + 1;
|
||||
if (src[j] === '+' || src[j] === '-') j++;
|
||||
if (isDigit(src[j])) {
|
||||
i = j;
|
||||
while (i < n && isDigit(src[i])) i++;
|
||||
}
|
||||
}
|
||||
tokens.push({ type: 'NUMBER', value: src.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// double-quoted string literal
|
||||
if (ch === '"') {
|
||||
const start = i;
|
||||
i++;
|
||||
let out = '';
|
||||
while (i < n && src[i] !== '"') {
|
||||
if (src[i] === '\\' && i + 1 < n) {
|
||||
out += src[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
out += src[i++];
|
||||
}
|
||||
}
|
||||
if (src[i] !== '"') throw new LexError(`Unterminated string at ${start}`);
|
||||
i++; // closing quote
|
||||
tokens.push({ type: 'STRING', value: out, pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// single-quoted sheet name: 'Kosten Q3' ('' -> literal quote)
|
||||
if (ch === "'") {
|
||||
const start = i;
|
||||
i++;
|
||||
let out = '';
|
||||
while (i < n) {
|
||||
if (src[i] === "'" && src[i + 1] === "'") {
|
||||
out += "'";
|
||||
i += 2;
|
||||
} else if (src[i] === "'") {
|
||||
break;
|
||||
} else {
|
||||
out += src[i++];
|
||||
}
|
||||
}
|
||||
if (src[i] !== "'") throw new LexError(`Unterminated sheet name at ${start}`);
|
||||
i++; // closing quote
|
||||
tokens.push({ type: 'QUOTED', value: out, pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// bare word: identifiers, function names, cell refs
|
||||
if (isWordStart(ch)) {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < n && isWordPart(src[i])) i++;
|
||||
tokens.push({ type: 'IDENT', value: src.slice(start, i), pos: start });
|
||||
continue;
|
||||
}
|
||||
|
||||
// "::" table qualifier -- must be checked before the single ":"
|
||||
if (ch === ':' && src[i + 1] === ':') {
|
||||
tokens.push({ type: 'DBLCOLON', value: '::', pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// two-character comparison operators -- must be checked before the
|
||||
// single-character "<"/">" fallback below
|
||||
if (ch === '<' && src[i + 1] === '=') {
|
||||
tokens.push({ type: 'LTE', value: '<=', pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '>' && src[i + 1] === '=') {
|
||||
tokens.push({ type: 'GTE', value: '>=', pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '<' && src[i + 1] === '>') {
|
||||
tokens.push({ type: 'NEQ', value: '<>', pos: i });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// an error-code literal, e.g. "#REF" or "#DIV/0!" -- lets a formula
|
||||
// literally contain/produce one, same as typing it in Excel; also how a
|
||||
// structural row/col delete marks a now-broken reference in formula text
|
||||
// (see store/document.ts's row/col delete). Recognized as one token
|
||||
// regardless of what follows (a subsequent "!"/"?" is already consumed
|
||||
// as part of the matched code, never re-tokenized as BANG etc.).
|
||||
if (ch === '#') {
|
||||
const code = ERROR_CODES_BY_LENGTH.find((c) => src.startsWith(c, i));
|
||||
if (code) {
|
||||
tokens.push({ type: 'ERRORLIT', value: code, pos: i });
|
||||
i += code.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// single-character punctuation / operators
|
||||
const t = SINGLE[ch];
|
||||
if (t) {
|
||||
tokens.push({ type: t, value: ch, pos: i });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new LexError(`Unexpected character '${ch}' at ${i}`);
|
||||
}
|
||||
|
||||
tokens.push({ type: 'EOF', value: '', pos: n });
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
/**
|
||||
* Recursive-descent parser: token stream -> AST.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* GRAMMAR (EBNF)
|
||||
* ---------------------------------------------------------------------------
|
||||
* formula = "=" expression ;
|
||||
*
|
||||
* expression = comparison ;
|
||||
* comparison = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ;
|
||||
* (* at most one comparison,
|
||||
* not chainable like
|
||||
* "a < b < c"; mainly
|
||||
* useful in a "block"
|
||||
* function body's IF
|
||||
* condition, but valid
|
||||
* in any formula *)
|
||||
* concat = additive { "&" additive } ; (* left assoc; text
|
||||
* concatenation, binds
|
||||
* looser than "+ -" but
|
||||
* tighter than a
|
||||
* comparison -- same
|
||||
* slot Excel gives "&" *)
|
||||
* additive = term { ("+" | "-") term } ; (* left assoc *)
|
||||
* term = unary { ("*" | "/") unary } ; (* left assoc *)
|
||||
* unary = ("+" | "-") unary
|
||||
* | power ;
|
||||
* power = primary [ "^" unary ] ; (* right assoc, and
|
||||
* binds tighter than
|
||||
* unary minus so that
|
||||
* -2^2 = -(2^2) = -4 *)
|
||||
* primary = NUMBER
|
||||
* | STRING
|
||||
* | ERRORLIT
|
||||
* | "(" expression ")"
|
||||
* | funcall
|
||||
* | reference ;
|
||||
*
|
||||
* funcall = IDENT "(" [ arg { ";" arg } ] ")" ;
|
||||
* arg = expression | (* empty *) ; (* an empty slot
|
||||
* between two ";"
|
||||
* (or "(...;)" right
|
||||
* before ")") is an
|
||||
* OmittedNode -- see
|
||||
* its doc comment;
|
||||
* "skip this optional
|
||||
* argument", e.g.
|
||||
* XLOOKUP(x;a;b;;-1) *)
|
||||
*
|
||||
* reference = [ sheetQualifier ] [ qualifier ] CELL [ ":" CELL ] ;
|
||||
* qualifier = (IDENT | QUOTED) ("!" | "::") ; (* table name; "!" and the
|
||||
* Numbers-style "::" are
|
||||
* interchangeable *)
|
||||
* sheetQualifier = (IDENT | QUOTED) "!" ; (* sheet name -- only
|
||||
* recognised when it is
|
||||
* immediately followed by
|
||||
* a full "table::" --
|
||||
* i.e. only in the 3-part
|
||||
* form "Blatt!Tabelle::A1".
|
||||
* A bare "X!Y" with no
|
||||
* "::" after Y keeps its
|
||||
* original meaning: X is
|
||||
* the TABLE qualifier
|
||||
* (backward-compatible --
|
||||
* see reference() below). *)
|
||||
* CELL = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
|
||||
* ERRORLIT = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
|
||||
* "#VALUE!" | "#N/A" ; (* a literal error code,
|
||||
* typed directly or
|
||||
* produced by a
|
||||
* structural row/col
|
||||
* delete *)
|
||||
*
|
||||
* Precedence, lowest to highest: comparison < & < + - < * / < unary +/- < ^
|
||||
* ---------------------------------------------------------------------------
|
||||
*
|
||||
* `primary` also accepts a bare IDENT as a *parameter reference* -- but only
|
||||
* when parseFormula() is called with a `paramNames` list (used to parse a
|
||||
* user-defined function's body; see src/engine/userFunctions.ts). Without
|
||||
* that list -- i.e. for every ordinary cell formula -- a bare, unqualified
|
||||
* IDENT that doesn't look like a cell address is instead a *named-range*
|
||||
* reference (`NamedRefNode`, see below): the parser has no opinion on
|
||||
* whether the name actually exists (same as it never validates a table
|
||||
* qualifier) -- an unresolvable one surfaces as `#NAME?` only at evaluation
|
||||
* time (see evaluator.ts's `resolveNamedRef()`). A *qualified* non-cell
|
||||
* identifier (`Tabelle!Foo`) still is a `ParseError` -- a named range is
|
||||
* always unqualified, so a qualifier always means "this must be a real
|
||||
* cell".
|
||||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { tokenize, type Token, type TokenType, LexError } from './lexer';
|
||||
import { looksLikeCell, parseA1, wholeLineAt, WHOLE_LINE_END } from './refs';
|
||||
import type { ErrorCode } from './types';
|
||||
|
||||
export type Node =
|
||||
| NumberNode
|
||||
| StringNode
|
||||
| ErrorLiteralNode
|
||||
| BinaryNode
|
||||
| UnaryNode
|
||||
| FuncNode
|
||||
| CellNode
|
||||
| RangeNode
|
||||
| NamedRefNode
|
||||
| ParamNode
|
||||
| BlockCallNode
|
||||
| OmittedNode
|
||||
| JsonLiteralNode;
|
||||
|
||||
export interface NumberNode {
|
||||
kind: 'number';
|
||||
value: number;
|
||||
}
|
||||
export interface StringNode {
|
||||
kind: 'string';
|
||||
value: string;
|
||||
}
|
||||
/** A literal error code typed directly into a formula, e.g. `=#REF!+1` or
|
||||
* `=IF(A1=0;#N/A;A1)` -- same as typing one in Excel. Evaluates directly to
|
||||
* that `FormulaError`, unconditionally (it's not the *result* of anything,
|
||||
* just a value like a number or string literal). Also how a structural
|
||||
* row/col delete represents a now-broken reference in stored formula text
|
||||
* (see store/document.ts). */
|
||||
export interface ErrorLiteralNode {
|
||||
kind: 'errorliteral';
|
||||
code: ErrorCode;
|
||||
}
|
||||
export interface BinaryNode {
|
||||
kind: 'binary';
|
||||
op: '+' | '-' | '*' | '/' | '^' | '&' | '=' | '<>' | '>' | '<' | '>=' | '<=';
|
||||
left: Node;
|
||||
right: Node;
|
||||
}
|
||||
export interface UnaryNode {
|
||||
kind: 'unary';
|
||||
op: '+' | '-';
|
||||
operand: Node;
|
||||
}
|
||||
export interface FuncNode {
|
||||
kind: 'func';
|
||||
name: string;
|
||||
args: Node[];
|
||||
}
|
||||
/** Single cell reference, optionally qualified with a table name. */
|
||||
export interface CellNode {
|
||||
kind: 'cell';
|
||||
/** raw table name as written ("Umsatz"), or null for "this table" */
|
||||
qualifier: string | null;
|
||||
/** raw sheet name as written ("Analyse") -- only set for the explicit
|
||||
* "Blatt!Tabelle::Zelle" 3-part form; absent for every plain
|
||||
* "Tabelle!Zelle" / "Tabelle::Zelle" reference (resolved without regard
|
||||
* to sheet, same as before this existed). */
|
||||
sheetQualifier?: string;
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
/** Rectangular range reference, optionally qualified with a table name. */
|
||||
export interface RangeNode {
|
||||
kind: 'range';
|
||||
qualifier: string | null;
|
||||
/** see {@link CellNode.sheetQualifier}. */
|
||||
sheetQualifier?: string;
|
||||
start: { col: number; row: number };
|
||||
end: { col: number; row: number };
|
||||
/** A whole-column (`B:B`, `A:C`) or whole-row (`1:1`, `2:5`) range: the
|
||||
* other axis runs from 0 to `WHOLE_LINE_END` (refs.ts), clamped to the
|
||||
* table's size wherever cells are read (`rangeCells`). */
|
||||
whole?: 'col' | 'row';
|
||||
}
|
||||
/** A bare, unqualified name that isn't a param and doesn't look like a cell
|
||||
* -- a candidate reference to a document-wide *named range* (`Umsatz_Q1`
|
||||
* instead of `Umsatz!B2:B13`), created/edited/removed through the "🏷
|
||||
* Namen" UI (see `src/store/types.ts`'s `NamedRange`). The parser doesn't
|
||||
* know the document's named ranges (same as it never validates a table
|
||||
* qualifier) -- resolved live, by name, at evaluation/dependency-collection
|
||||
* time via `EngineHost.resolveNamedRange()`; an unknown name is `#NAME?`. */
|
||||
export interface NamedRefNode {
|
||||
kind: 'namedref';
|
||||
name: string;
|
||||
}
|
||||
/** A reference to a user-defined function's own parameter -- only produced
|
||||
* when parseFormula() is given a `paramNames` list (parsing a function
|
||||
* body); never appears in an ordinary cell formula's AST. Resolved away by
|
||||
* userFunctions.ts's expandUserFunctions() before evaluation ever sees it. */
|
||||
export interface ParamNode {
|
||||
kind: 'param';
|
||||
name: string;
|
||||
}
|
||||
/** An empty argument slot between two `;` (or before the closing `)`), e.g.
|
||||
* the middle `;;` in `XLOOKUP(x;a;b;;-1)`, skipping the optional
|
||||
* `WennNichtGefunden` argument to reach `Vergleichsmodus` -- Excel's own
|
||||
* "leave this optional argument out" idiom. Evaluates to `null` (empty
|
||||
* cell), same as any other missing value in this engine. A function that
|
||||
* needs to tell "explicitly omitted" apart from "evaluated to null" (e.g.
|
||||
* XLOOKUP's optional trailing arguments) checks `arg.kind === 'omitted'`
|
||||
* on the raw argument `Node` directly, before ever evaluating it. */
|
||||
export interface OmittedNode {
|
||||
kind: 'omitted';
|
||||
}
|
||||
/** A JSON value flowing through a macro's `env` (a `GET`/`POST` response, a
|
||||
* `JSONGET` result, or one element while a `FOR` iterates a JSON array) --
|
||||
* see evaluator.ts's `execStatementAsync`/`jsonGetPath`. Never produced by
|
||||
* this parser (there is no JSON-literal syntax in the language) -- only
|
||||
* the macro interpreter ever constructs one, wraps it in `literalNodeOf`-
|
||||
* style env binding, and hands it back through `substitute()` like any
|
||||
* other bound name. `evalScalar`'s `case 'json'` is where it collapses to
|
||||
* an ordinary `CellValue` if used in plain scalar position without first
|
||||
* drilling into it via `JSONGET`. */
|
||||
export interface JsonLiteralNode {
|
||||
kind: 'json';
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Block" function bodies (`kind: 'block'` custom functions) -- a small
|
||||
// statement grammar with real control flow (FOR/DO/END loops, IF/THEN/
|
||||
// ELSE/END branches, RETURN anywhere). NOT part of the `Node` union above:
|
||||
// a block is parsed by parseFunctionBlock() (a distinct entry point from
|
||||
// parseFormula()), and -- unlike Phase 1's single-expression bodies, which
|
||||
// are fully inlined at parse time -- a block is only *partly* resolved at
|
||||
// parse time (nested calls to other functions) and then *interpreted* at
|
||||
// evaluation time by evaluator.ts, since a conditional/early RETURN depends
|
||||
// on real cell values that aren't known until then. `BlockCallNode` (further
|
||||
// below) is the one piece of this that *does* end up embedded in a normal
|
||||
// cell AST and so *is* part of `Node`. See src/engine/README.md's
|
||||
// "Schleifen" section for the full design.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `name = value` -- a local-variable assignment. */
|
||||
export interface AssignNode {
|
||||
kind: 'assign';
|
||||
name: string;
|
||||
value: Node;
|
||||
}
|
||||
/** `RETURN value` -- may appear anywhere in a block (top level or nested in
|
||||
* a FOR/IF), not just at the end; ends the function's execution immediately
|
||||
* with this value once reached. */
|
||||
export interface ReturnNode {
|
||||
kind: 'return';
|
||||
value: Node;
|
||||
}
|
||||
/** `FOR varName IN source DO body END` -- source must resolve, once
|
||||
* parameters are substituted with the caller's real arguments, to a single
|
||||
* cell or a range (checked by userFunctions.ts at save time, enforced by
|
||||
* the interpreter at call time); `body` runs once per cell, in order. */
|
||||
export interface ForNode {
|
||||
kind: 'for';
|
||||
varName: string;
|
||||
source: Node;
|
||||
body: BlockNode;
|
||||
}
|
||||
/** `IF cond THEN then [ELSE else] END`. May appear in any block, including
|
||||
* nested inside a FOR's body or another IF's branch. */
|
||||
export interface IfNode {
|
||||
kind: 'ifstmt';
|
||||
cond: Node;
|
||||
then: BlockNode;
|
||||
else: BlockNode | null;
|
||||
}
|
||||
/** `NAME(arg; arg; ...)` at statement position -- a call into the macro
|
||||
* write-action vocabulary (`SETVALUE`, `INSERTROW`, `ADDTABLE`, ...; see
|
||||
* engine/types.ts's `MacroWriteHost` and evaluator.ts's `execStatement`).
|
||||
* Only ever produced when the parser's `allowWrites` flag is on, which is
|
||||
* only true for `parseMacroBlock()` -- so this can *never* appear in a
|
||||
* `kind: 'block'` custom function's body (always parsed with
|
||||
* `allowWrites: false`), meaning a custom function embedded inside a
|
||||
* macro's expressions can compute a value but can never itself write. */
|
||||
export interface ActionNode {
|
||||
kind: 'action';
|
||||
name: string;
|
||||
args: Node[];
|
||||
}
|
||||
export type Statement = AssignNode | ForNode | IfNode | ReturnNode | ActionNode;
|
||||
/** A sequence of statements -- a whole function body, or the body of a
|
||||
* FOR/IF. Every name a statement introduces (assignment target or loop
|
||||
* variable) stays visible for the *rest* of the execution, including after
|
||||
* its own FOR/IF ends (with its last value) -- flat scope, no nested block
|
||||
* scoping. */
|
||||
export interface BlockNode {
|
||||
kind: 'block';
|
||||
statements: Statement[];
|
||||
}
|
||||
/** The one block-related node that *is* part of `Node`: embedded into a
|
||||
* normal cell AST wherever a `kind: 'block'` custom function is called
|
||||
* (userFunctions.ts#expand(), replacing what a `kind: 'expression'` call
|
||||
* would have inlined as a plain expression). `block` has already had every
|
||||
* *other* function call it contains resolved (built-ins untouched, other
|
||||
* custom functions inlined/turned into their own BlockCallNode) -- only
|
||||
* this function's *own* parameters, referenced via ParamNode inside
|
||||
* `block`, remain to be bound, which evaluator.ts's interpreter does at
|
||||
* evaluation time using `args`/`paramNames`. */
|
||||
export interface BlockCallNode {
|
||||
kind: 'blockcall';
|
||||
block: BlockNode;
|
||||
args: Node[];
|
||||
paramNames: string[];
|
||||
/** the function's own name, purely for a clearer "ended without RETURN"
|
||||
* error message. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export class ParseError extends Error {}
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
/** Case-insensitively matched (compared upper-cased), like function names
|
||||
* elsewhere in the engine (functions.ts's getFunction()). Mutable only for
|
||||
* parseBlock() below: as each statement introduces a new local/loop-var
|
||||
* name, it's added here so *later* statements/the RETURN can reference it
|
||||
* -- a plain expression body (parseFormula()'s normal path) never mutates
|
||||
* this, so its behaviour is completely unaffected. */
|
||||
private readonly paramNames: Set<string>;
|
||||
/** Whether a bare, unqualified, non-cell, non-param identifier falls back
|
||||
* to a `NamedRefNode` (an ordinary top-level formula) instead of a hard
|
||||
* `ParseError` (parsing a function body) -- see reference()'s named-range
|
||||
* branch and parseFormula()'s doc comment for why function bodies opt
|
||||
* out of the fallback. */
|
||||
private readonly namedRefFallback: boolean;
|
||||
/** Whether `NAME(args)` at statement position parses as an `ActionNode`
|
||||
* (a macro write-action call) instead of falling through to
|
||||
* `parseAssignment()` (which then fails, since assignment requires "="
|
||||
* right after the name). Only `parseMacroBlock()` turns this on -- see
|
||||
* `ActionNode`'s doc comment for why that containment matters. */
|
||||
private readonly allowWrites: boolean;
|
||||
constructor(
|
||||
private readonly toks: Token[],
|
||||
paramNames: readonly string[] = [],
|
||||
namedRefFallback = true,
|
||||
allowWrites = false,
|
||||
) {
|
||||
this.paramNames = new Set(paramNames.map((p) => p.toUpperCase()));
|
||||
this.namedRefFallback = namedRefFallback;
|
||||
this.allowWrites = allowWrites;
|
||||
}
|
||||
|
||||
private peek(): Token {
|
||||
return this.toks[this.i];
|
||||
}
|
||||
private next(): Token {
|
||||
return this.toks[this.i++];
|
||||
}
|
||||
private at(t: TokenType): boolean {
|
||||
return this.toks[this.i].type === t;
|
||||
}
|
||||
private expect(t: TokenType): Token {
|
||||
if (!this.at(t)) {
|
||||
throw new ParseError(`Expected ${t} but got ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return this.next();
|
||||
}
|
||||
|
||||
parse(): Node {
|
||||
const node = this.expression();
|
||||
if (!this.at('EOF')) {
|
||||
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Entry point for a "block" function body: a plain statement sequence
|
||||
* (`RETURN` is just one of the statement kinds now, usable anywhere --
|
||||
* see parseFunctionBlock() below). */
|
||||
parseBlock(): BlockNode {
|
||||
const block = this.parseStatementBlock([]);
|
||||
if (!this.at('EOF')) {
|
||||
throw new ParseError(`Unexpected trailing token ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
/** Parses statements until the current token is EOF or one of `stopWords`
|
||||
* (a keyword that ends the enclosing construct -- "END"/"ELSE" -- left
|
||||
* *unconsumed* for the caller to check/consume itself). */
|
||||
private parseStatementBlock(stopWords: readonly string[]): BlockNode {
|
||||
const statements: Statement[] = [];
|
||||
while (!this.at('EOF') && !stopWords.some((w) => this.isKeyword(w))) {
|
||||
statements.push(this.parseStatement());
|
||||
}
|
||||
return { kind: 'block', statements };
|
||||
}
|
||||
|
||||
private parseStatement(): Statement {
|
||||
if (this.isKeyword('FOR')) return this.parseForLoop();
|
||||
if (this.isKeyword('IF')) return this.parseIfStmt();
|
||||
if (this.isKeyword('RETURN')) return this.parseReturnStmt();
|
||||
// "NAME(" at statement start, only inside a macro body -- unambiguous
|
||||
// vs. assignment, which requires "=" (a different token type) right
|
||||
// after the name.
|
||||
if (this.allowWrites && this.at('IDENT') && this.toks[this.i + 1]?.type === 'LPAREN') {
|
||||
return this.parseAction();
|
||||
}
|
||||
return this.parseAssignment();
|
||||
}
|
||||
|
||||
private parseAction(): ActionNode {
|
||||
const nameTok = this.next(); // IDENT, guaranteed by the caller's lookahead
|
||||
this.next(); // consume "("
|
||||
const args = this.parseArgList();
|
||||
this.expect('RPAREN');
|
||||
return { kind: 'action', name: nameTok.value, args };
|
||||
}
|
||||
|
||||
/** `[ arg { ";" arg } ]` -- shared by a function-call expression
|
||||
* (`primary()`) and a statement-position action call (`parseAction()`).
|
||||
* An empty slot (leading/middle/trailing `;`, right up against `;` or
|
||||
* the caller's closing `)`) parses as an `OmittedNode`, same as a
|
||||
* function call's own argument list -- the caller consumes the closing
|
||||
* `)` itself. */
|
||||
private parseArgList(): Node[] {
|
||||
const args: Node[] = [];
|
||||
const argOrOmitted = (): Node =>
|
||||
this.at('SEMICOLON') || this.at('RPAREN') ? { kind: 'omitted' } : this.expression();
|
||||
if (!this.at('RPAREN')) {
|
||||
args.push(argOrOmitted());
|
||||
while (this.at('SEMICOLON')) {
|
||||
this.next();
|
||||
args.push(argOrOmitted());
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
private parseAssignment(): AssignNode {
|
||||
if (!this.at('IDENT')) {
|
||||
throw new ParseError(`Expected a name but got ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
const nameTok = this.next();
|
||||
if (!this.at('EQUALS')) {
|
||||
throw new ParseError(`Expected '=' after '${nameTok.value}' at ${this.peek().pos}`);
|
||||
}
|
||||
this.next();
|
||||
const value = this.expression();
|
||||
this.paramNames.add(nameTok.value.toUpperCase()); // visible from here on (flat scope)
|
||||
return { kind: 'assign', name: nameTok.value, value };
|
||||
}
|
||||
|
||||
private parseReturnStmt(): ReturnNode {
|
||||
this.next(); // consume RETURN
|
||||
const value = this.expression();
|
||||
return { kind: 'return', value };
|
||||
}
|
||||
|
||||
private parseForLoop(): ForNode {
|
||||
this.next(); // consume FOR
|
||||
if (!this.at('IDENT')) {
|
||||
throw new ParseError(`Expected a loop variable name after FOR at ${this.peek().pos}`);
|
||||
}
|
||||
const varTok = this.next();
|
||||
this.expectKeyword('IN', `FOR ${varTok.value}`);
|
||||
const source = this.expression();
|
||||
this.expectKeyword('DO', 'the FOR source');
|
||||
this.paramNames.add(varTok.value.toUpperCase()); // loop var visible in the body (and after)
|
||||
const body = this.parseStatementBlock(['END']);
|
||||
this.expectKeyword('END', 'the FOR body');
|
||||
return { kind: 'for', varName: varTok.value, source, body };
|
||||
}
|
||||
|
||||
private parseIfStmt(): IfNode {
|
||||
this.next(); // consume IF
|
||||
const cond = this.expression();
|
||||
this.expectKeyword('THEN', 'the IF condition');
|
||||
const then = this.parseStatementBlock(['ELSE', 'END']);
|
||||
let elseBlock: BlockNode | null = null;
|
||||
if (this.isKeyword('ELSE')) {
|
||||
this.next();
|
||||
elseBlock = this.parseStatementBlock(['END']);
|
||||
}
|
||||
this.expectKeyword('END', 'the IF body');
|
||||
return { kind: 'ifstmt', cond, then, else: elseBlock };
|
||||
}
|
||||
|
||||
/** True iff the current token is an IDENT spelling exactly `word`
|
||||
* (case-insensitive) -- how FOR/IN/DO/END/IF/THEN/ELSE/RETURN are
|
||||
* recognised, the same way function names are matched elsewhere (no
|
||||
* reserved-word lexer tokens). */
|
||||
private isKeyword(word: string): boolean {
|
||||
return this.at('IDENT') && this.peek().value.toUpperCase() === word;
|
||||
}
|
||||
|
||||
/** Consumes `word` as a keyword, or throws a ParseError naming what came
|
||||
* before it (for a clearer message than a generic "expected IDENT"). */
|
||||
private expectKeyword(word: string, afterWhat: string): void {
|
||||
if (!this.isKeyword(word)) {
|
||||
throw new ParseError(`Expected ${word} after ${afterWhat} at ${this.peek().pos}`);
|
||||
}
|
||||
this.next();
|
||||
}
|
||||
|
||||
/** Public entry point for every expression context (function args,
|
||||
* parens, assignment RHS, ...) -- wraps `additive` with an optional
|
||||
* single comparison, the loosest-binding operator. */
|
||||
private expression(): Node {
|
||||
const left = this.concat();
|
||||
const opType = this.comparisonOpType();
|
||||
if (!opType) return left;
|
||||
this.next();
|
||||
return { kind: 'binary', op: opType, left, right: this.concat() };
|
||||
}
|
||||
|
||||
private comparisonOpType(): BinaryNode['op'] | null {
|
||||
switch (this.peek().type) {
|
||||
case 'EQUALS':
|
||||
return '=';
|
||||
case 'NEQ':
|
||||
return '<>';
|
||||
case 'GT':
|
||||
return '>';
|
||||
case 'LT':
|
||||
return '<';
|
||||
case 'GTE':
|
||||
return '>=';
|
||||
case 'LTE':
|
||||
return '<=';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `&` -- text concatenation, left-associative. Binds looser than `+ -`
|
||||
* (so `="a"&1+2` is `"a"&(1+2)` = `"a3"`, not `("a"&1)+2`) but tighter
|
||||
* than a comparison (so `=A1&B1="ab"` is `(A1&B1)="ab"`, not
|
||||
* `A1&(B1="ab")`) -- same slot Excel gives `&`. */
|
||||
private concat(): Node {
|
||||
let left = this.additive();
|
||||
while (this.at('AMP')) {
|
||||
this.next();
|
||||
left = { kind: 'binary', op: '&', left, right: this.additive() };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
/** The old `expression` rule -- `+`/`-`, left-associative -- renamed once
|
||||
* `expression` itself gained the comparison wrapper above it. */
|
||||
private additive(): Node {
|
||||
let left = this.term();
|
||||
while (this.at('PLUS') || this.at('MINUS')) {
|
||||
const op = this.next().type === 'PLUS' ? '+' : '-';
|
||||
left = { kind: 'binary', op, left, right: this.term() };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private term(): Node {
|
||||
let left = this.unary();
|
||||
while (this.at('STAR') || this.at('SLASH')) {
|
||||
const op = this.next().type === 'STAR' ? '*' : '/';
|
||||
left = { kind: 'binary', op, left, right: this.unary() };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private unary(): Node {
|
||||
if (this.at('PLUS') || this.at('MINUS')) {
|
||||
const op = this.next().type === 'PLUS' ? '+' : '-';
|
||||
return { kind: 'unary', op, operand: this.unary() };
|
||||
}
|
||||
return this.power();
|
||||
}
|
||||
|
||||
private power(): Node {
|
||||
const base = this.primary();
|
||||
if (this.at('CARET')) {
|
||||
this.next();
|
||||
// right operand is `unary` -> right-associative and allows -exponent
|
||||
return { kind: 'binary', op: '^', left: base, right: this.unary() };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
private primary(): Node {
|
||||
const t = this.peek();
|
||||
|
||||
if (t.type === 'NUMBER') {
|
||||
// `1:1` / `2:5` -- a whole-row reference, not a number
|
||||
if (wholeLineAt(this.toks, this.i)) return this.reference(null, null);
|
||||
this.next();
|
||||
return { kind: 'number', value: Number(t.value) };
|
||||
}
|
||||
if (t.type === 'STRING') {
|
||||
this.next();
|
||||
return { kind: 'string', value: t.value };
|
||||
}
|
||||
if (t.type === 'ERRORLIT') {
|
||||
this.next();
|
||||
return { kind: 'errorliteral', code: t.value as ErrorCode };
|
||||
}
|
||||
if (t.type === 'LPAREN') {
|
||||
this.next();
|
||||
const inner = this.expression();
|
||||
this.expect('RPAREN');
|
||||
return inner;
|
||||
}
|
||||
if (t.type === 'QUOTED') {
|
||||
// must be a qualified reference -- afterFirstQualifierToken() figures
|
||||
// out whether it's a table qualifier ('Kosten Q3'!B2) or a sheet
|
||||
// qualifier starting a 3-part ('Analyse'!Umsatz::B2) reference.
|
||||
this.next();
|
||||
return this.afterFirstQualifierToken(t.value);
|
||||
}
|
||||
if (t.type === 'IDENT') {
|
||||
this.next();
|
||||
// function call?
|
||||
if (this.at('LPAREN')) {
|
||||
this.next();
|
||||
const args = this.parseArgList();
|
||||
this.expect('RPAREN');
|
||||
return { kind: 'func', name: t.value, args };
|
||||
}
|
||||
// qualified reference? IDENT ("!" | "::") ...
|
||||
if (this.at('BANG') || this.at('DBLCOLON')) {
|
||||
return this.afterFirstQualifierToken(t.value);
|
||||
}
|
||||
// otherwise it must be a local cell reference
|
||||
return this.reference(null, null, t);
|
||||
}
|
||||
|
||||
throw new ParseError(`Unexpected token ${t.type} at ${t.pos}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called right after consuming the first IDENT/QUOTED of what is *some*
|
||||
* kind of qualified reference (current token is known to be "!" or "::").
|
||||
* Disambiguates the two qualifier grammars:
|
||||
* - "X::..." or a bare "X!..." (not followed by another name + "::") is
|
||||
* the original, single-level TABLE qualifier -- unchanged from before
|
||||
* this existed, so every formula written before the sheet qualifier
|
||||
* was added still parses exactly the same.
|
||||
* - "X!Y::..." -- when "!" is immediately followed by another
|
||||
* IDENT/QUOTED and *that* is followed by "::" -- is the 3-part form:
|
||||
* X is the SHEET, Y is the TABLE.
|
||||
*/
|
||||
private afterFirstQualifierToken(first: string): Node {
|
||||
if (this.at('BANG') && this.looksLikeSheetThenTable()) {
|
||||
this.next(); // consume "!"
|
||||
const tableTok = this.next(); // IDENT or QUOTED, guaranteed by the lookahead above
|
||||
this.next(); // consume "::"
|
||||
return this.reference(tableTok.value, first);
|
||||
}
|
||||
if (this.at('BANG') || this.at('DBLCOLON')) {
|
||||
this.next();
|
||||
return this.reference(first, null);
|
||||
}
|
||||
throw new ParseError(`Expected '!' or '::' but got ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
|
||||
/** True iff, with the current token being "!", the shape ahead is
|
||||
* IDENT/QUOTED "::" -- i.e. this "!" really introduces a SHEET name
|
||||
* followed by a table qualifier, not a bare (old-style) table qualifier
|
||||
* of its own. Pure lookahead, consumes nothing. */
|
||||
private looksLikeSheetThenTable(): boolean {
|
||||
const n1 = this.toks[this.i + 1];
|
||||
const n2 = this.toks[this.i + 2];
|
||||
return !!n1 && (n1.type === 'IDENT' || n1.type === 'QUOTED') && !!n2 && n2.type === 'DBLCOLON';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the cell/range part of a reference. `firstTok` (when given) is an
|
||||
* IDENT already consumed by the caller for the *local* case.
|
||||
*/
|
||||
private reference(qualifier: string | null, sheetQualifier: string | null, firstTok?: Token): Node {
|
||||
// whole column `B:B` / whole row `1:1` -- checked first: `B` alone would
|
||||
// otherwise be a named-range candidate and `1` a number
|
||||
const line = wholeLineAt(this.toks, firstTok ? this.i - 1 : this.i);
|
||||
if (line) {
|
||||
if (!firstTok) this.next();
|
||||
this.next(); // ":"
|
||||
this.next(); // second part
|
||||
const sheetField = sheetQualifier === null ? {} : { sheetQualifier };
|
||||
return line.axis === 'col'
|
||||
? {
|
||||
kind: 'range',
|
||||
qualifier,
|
||||
...sheetField,
|
||||
start: { col: line.start, row: 0 },
|
||||
end: { col: line.end, row: WHOLE_LINE_END },
|
||||
whole: 'col',
|
||||
}
|
||||
: {
|
||||
kind: 'range',
|
||||
qualifier,
|
||||
...sheetField,
|
||||
start: { col: 0, row: line.start },
|
||||
end: { col: WHOLE_LINE_END, row: line.end },
|
||||
whole: 'row',
|
||||
};
|
||||
}
|
||||
const a = firstTok ?? this.expectCellIdent();
|
||||
if (!firstTok && a.type !== 'IDENT') {
|
||||
throw new ParseError(`Expected cell reference at ${a.pos}`);
|
||||
}
|
||||
// an unqualified name matching a declared parameter -- only reachable
|
||||
// when parseFormula() was given a paramNames list; a param is always a
|
||||
// scalar, so it's never part of a qualifier or a ":"-range, only ever
|
||||
// this single bare token.
|
||||
if (qualifier === null && this.paramNames.has(a.value.toUpperCase())) {
|
||||
return { kind: 'param', name: a.value };
|
||||
}
|
||||
// an unqualified, non-cell-shaped bare name -- a named-range candidate
|
||||
// (see NamedRefNode's doc comment), but only for an ordinary top-level
|
||||
// formula (namedRefFallback); a function body keeps the strict old
|
||||
// behaviour so e.g. a local used before its own declaring statement
|
||||
// still fails immediately at save time instead of silently becoming a
|
||||
// named-range lookup (or worse, one that happens to resolve because a
|
||||
// same-named named range exists elsewhere in the document) -- a caller
|
||||
// can still *pass* a named range as an argument, it just can't be typed
|
||||
// bare inside the body itself. Never a range-start either way: "Name:B2"
|
||||
// isn't supported, so this doesn't fall through to the ":"-range check.
|
||||
if (qualifier === null && !looksLikeCell(a.value) && this.namedRefFallback) {
|
||||
return { kind: 'namedref', name: a.value };
|
||||
}
|
||||
if (!looksLikeCell(a.value)) {
|
||||
throw new ParseError(`'${a.value}' is not a valid cell reference (pos ${a.pos})`);
|
||||
}
|
||||
const start = parseA1(a.value);
|
||||
const sheetField = sheetQualifier === null ? {} : { sheetQualifier };
|
||||
|
||||
if (this.at('COLON')) {
|
||||
this.next();
|
||||
const b = this.expectCellIdent();
|
||||
if (!looksLikeCell(b.value)) {
|
||||
throw new ParseError(`'${b.value}' is not a valid cell reference (pos ${b.pos})`);
|
||||
}
|
||||
const end = parseA1(b.value);
|
||||
return { kind: 'range', qualifier, ...sheetField, start, end };
|
||||
}
|
||||
return { kind: 'cell', qualifier, ...sheetField, col: start.col, row: start.row };
|
||||
}
|
||||
|
||||
private expectCellIdent(): Token {
|
||||
if (!this.at('IDENT')) {
|
||||
throw new ParseError(`Expected cell reference but got ${this.peek().type} at ${this.peek().pos}`);
|
||||
}
|
||||
return this.next();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw formula. The leading "=" is optional (callers usually strip it).
|
||||
* Throws {@link ParseError} / {@link LexError} on malformed input; the store
|
||||
* turns those into the `#ERR` cell value.
|
||||
*
|
||||
* `paramNames`, when given, is a user-defined function's parameter list --
|
||||
* bare identifiers matching one of them parse as a {@link ParamNode} instead
|
||||
* of requiring cell-address shape. Omit it (the default) for every ordinary
|
||||
* cell formula; behaviour is then identical to before this existed, *except*
|
||||
* that a bare, unqualified, non-cell identifier now parses as a
|
||||
* {@link NamedRefNode} (a named-range candidate) instead of a ParseError --
|
||||
* see NamedRefNode's doc comment. That fallback only applies when
|
||||
* `paramNames` is omitted entirely (`undefined`) -- passing it, even as an
|
||||
* empty array, means "this is a function body", where the fallback is
|
||||
* deliberately off (see the Parser class's `namedRefFallback` field).
|
||||
*/
|
||||
export function parseFormula(src: string, paramNames?: readonly string[]): Node {
|
||||
const body = src.startsWith('=') ? src.slice(1) : src;
|
||||
let toks: Token[];
|
||||
try {
|
||||
toks = tokenize(body);
|
||||
} catch (e) {
|
||||
if (e instanceof LexError) throw new ParseError(e.message);
|
||||
throw e;
|
||||
}
|
||||
return new Parser(toks, paramNames, paramNames === undefined).parse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a "block" function body -- the statement grammar (assignments,
|
||||
* `FOR`/`DO`/`END`, `IF`/`THEN`/`ELSE`/`END`, `RETURN` anywhere) used by a
|
||||
* `kind: 'block'` custom function (see src/engine/userFunctions.ts,
|
||||
* evaluator.ts's interpreter, and CustomFunctionsModal.vue). The leading
|
||||
* "=" is optional, same as parseFormula(). Throws {@link ParseError} /
|
||||
* {@link LexError} on malformed input, same conventions as parseFormula().
|
||||
*/
|
||||
export function parseFunctionBlock(src: string, paramNames: readonly string[]): BlockNode {
|
||||
const body = src.startsWith('=') ? src.slice(1) : src;
|
||||
let toks: Token[];
|
||||
try {
|
||||
toks = tokenize(body);
|
||||
} catch (e) {
|
||||
if (e instanceof LexError) throw new ParseError(e.message);
|
||||
throw e;
|
||||
}
|
||||
return new Parser(toks, paramNames, false).parseBlock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a macro body -- the same statement grammar as `parseFunctionBlock`
|
||||
* (assignments, `FOR`/`IF`/`RETURN`), plus `NAME(args)` write-action calls
|
||||
* (`ActionNode`; see its doc comment), which only this entry point enables.
|
||||
* A macro takes no parameters (triggered by a click, not called with
|
||||
* arguments) and gets no named-range fallback (same reasoning as a function
|
||||
* body: a bare identifier used before its own declaring statement should
|
||||
* fail immediately, not silently resolve to a same-named document-wide
|
||||
* named range) -- so `paramNames`/`namedRefFallback` are fixed at `[]`/
|
||||
* `false`, only `allowWrites` differs from `parseFunctionBlock`.
|
||||
*/
|
||||
export function parseMacroBlock(src: string): BlockNode {
|
||||
const body = src.startsWith('=') ? src.slice(1) : src;
|
||||
let toks: Token[];
|
||||
try {
|
||||
toks = tokenize(body);
|
||||
} catch (e) {
|
||||
if (e instanceof LexError) throw new ParseError(e.message);
|
||||
throw e;
|
||||
}
|
||||
return new Parser(toks, [], false, true).parseBlock();
|
||||
}
|
||||
|
||||
/** Walk an AST and collect every cell/range/named-range reference node it
|
||||
* contains. For a `blockcall`, walks both its (already-resolved) `args` and
|
||||
* every expression reachable inside its `block` -- conservatively: a
|
||||
* reference inside a branch/loop that might not execute at runtime still
|
||||
* counts as a dependency, same as an ordinary `IF`-style formula in any
|
||||
* spreadsheet. A `NamedRefNode` is collected as-is, unresolved -- this
|
||||
* function is host-agnostic (doesn't know the document's named ranges),
|
||||
* same as it never resolves a table qualifier either; the caller
|
||||
* (`Workbook.precedentsOf`) is the one that turns it into concrete cell
|
||||
* keys via `resolveNamedRange()`. */
|
||||
export function collectRefs(
|
||||
node: Node,
|
||||
out: Array<CellNode | RangeNode | NamedRefNode> = [],
|
||||
): Array<CellNode | RangeNode | NamedRefNode> {
|
||||
switch (node.kind) {
|
||||
case 'cell':
|
||||
case 'range':
|
||||
case 'namedref':
|
||||
out.push(node);
|
||||
break;
|
||||
case 'binary':
|
||||
collectRefs(node.left, out);
|
||||
collectRefs(node.right, out);
|
||||
break;
|
||||
case 'unary':
|
||||
collectRefs(node.operand, out);
|
||||
break;
|
||||
case 'func':
|
||||
node.args.forEach((a) => collectRefs(a, out));
|
||||
break;
|
||||
case 'blockcall':
|
||||
node.args.forEach((a) => collectRefs(a, out));
|
||||
collectRefsInBlock(node.block, out);
|
||||
break;
|
||||
case 'json':
|
||||
break; // a JSON value carries no cell/range/named-range refs of its own
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Exported for the macro save-time validator (store/document.ts), which
|
||||
* walks every `CellNode`/`RangeNode` this finds to reject an unqualified
|
||||
* reference -- a macro has no "home table" a bare `A1` could mean, unlike
|
||||
* a formula cell (see store/document.ts's `validateMacro`). */
|
||||
export function collectRefsInBlock(
|
||||
block: BlockNode,
|
||||
out: Array<CellNode | RangeNode | NamedRefNode> = [],
|
||||
): Array<CellNode | RangeNode | NamedRefNode> {
|
||||
for (const stmt of block.statements) {
|
||||
switch (stmt.kind) {
|
||||
case 'assign':
|
||||
collectRefs(stmt.value, out);
|
||||
break;
|
||||
case 'return':
|
||||
collectRefs(stmt.value, out);
|
||||
break;
|
||||
case 'for':
|
||||
collectRefs(stmt.source, out);
|
||||
collectRefsInBlock(stmt.body, out);
|
||||
break;
|
||||
case 'ifstmt':
|
||||
collectRefs(stmt.cond, out);
|
||||
collectRefsInBlock(stmt.then, out);
|
||||
if (stmt.else) collectRefsInBlock(stmt.else, out);
|
||||
break;
|
||||
case 'action':
|
||||
stmt.args.forEach((a) => collectRefs(a, out));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Reference helpers: A1 <-> (col,row) conversion and range expansion.
|
||||
*
|
||||
* All coordinates the engine uses internally are 0-based. The A1 notation
|
||||
* exposed to the user is 1-based for rows and letter-based for columns
|
||||
* (A=0, B=1, ..., Z=25, AA=26, ...).
|
||||
*/
|
||||
|
||||
import { tokenize, type Token } from './lexer';
|
||||
|
||||
const CELL_RE = /^(\$?)([A-Za-z]{1,3})(\$?)([0-9]{1,7})$/;
|
||||
|
||||
/** "A" -> 0, "B" -> 1, "Z" -> 25, "AA" -> 26 ... */
|
||||
export function colToNumber(letters: string): number {
|
||||
let n = 0;
|
||||
const up = letters.toUpperCase();
|
||||
for (let i = 0; i < up.length; i++) {
|
||||
n = n * 26 + (up.charCodeAt(i) - 64); // 'A' -> 1
|
||||
}
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
/** 0 -> "A", 25 -> "Z", 26 -> "AA" ... */
|
||||
export function numberToCol(n: number): string {
|
||||
let s = '';
|
||||
let x = n + 1;
|
||||
while (x > 0) {
|
||||
const rem = (x - 1) % 26;
|
||||
s = String.fromCharCode(65 + rem) + s;
|
||||
x = Math.floor((x - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** True if `text` looks like a bare cell reference (e.g. "A1", "$B$2", "aa10"). */
|
||||
export function looksLikeCell(text: string): boolean {
|
||||
return CELL_RE.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse "A1" / "$A1" / "A$1" / "$A$1" -> 0-based coords plus which axes are
|
||||
* `$`-locked (absolute). A locked axis is exempt from the reference shift a
|
||||
* copy/fill applies (see `shiftFormula` in the store); an unlocked one is
|
||||
* "relative" and always shifts with it. Throws for malformed input.
|
||||
*/
|
||||
export function parseA1(
|
||||
text: string,
|
||||
): { col: number; row: number; colAbs: boolean; rowAbs: boolean } {
|
||||
const m = CELL_RE.exec(text);
|
||||
if (!m) throw new Error(`Bad cell reference: ${text}`);
|
||||
return { col: colToNumber(m[2]), row: Number(m[4]) - 1, colAbs: m[1] === '$', rowAbs: m[3] === '$' };
|
||||
}
|
||||
|
||||
/** Build "A1" from 0-based coordinates; `colAbs`/`rowAbs` add the `$` lock markers. */
|
||||
export function toA1(col: number, row: number, colAbs = false, rowAbs = false): string {
|
||||
return `${colAbs ? '$' : ''}${numberToCol(col)}${rowAbs ? '$' : ''}${row + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel/Numbers-style F4: cycle every cell reference through
|
||||
* relative -> absolute -> row-locked -> column-locked -> relative, each
|
||||
* from its own current state.
|
||||
*
|
||||
* With a collapsed selection (`selStart === selEnd`, the common case -- just
|
||||
* a caret), this toggles the single reference at/adjacent to it. With a real
|
||||
* text selection, it toggles *every* reference the selection overlaps at
|
||||
* once -- select a stretch of formula text spanning several references and
|
||||
* F4 adjusts them all in one press, same as Excel.
|
||||
*
|
||||
* `selStart`/`selEnd` are 0-based offsets into `formula` (which must start
|
||||
* with "="), i.e. straight from an `<input>`/`<textarea>`'s
|
||||
* `selectionStart`/`selectionEnd`. Returns the rewritten formula and a
|
||||
* selection re-spanning the (possibly resized) references, so a repeated F4
|
||||
* keeps cycling the same ones; `null` when none were found.
|
||||
*/
|
||||
export function cycleReferenceLocks(
|
||||
formula: string,
|
||||
selStart: number,
|
||||
selEnd: number,
|
||||
): { text: string; start: number; end: number } | null {
|
||||
if (!formula.startsWith('=')) return null;
|
||||
const body = formula.slice(1);
|
||||
const lo = Math.min(selStart, selEnd) - 1;
|
||||
const hi = Math.max(selStart, selEnd) - 1;
|
||||
const collapsed = lo === hi;
|
||||
let toks: Token[];
|
||||
try {
|
||||
toks = tokenize(body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const targets: Token[] = [];
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
const t = toks[i];
|
||||
if (t.type !== 'IDENT' || !looksLikeCell(t.value)) continue;
|
||||
const next = toks[i + 1];
|
||||
if (next && (next.type === 'BANG' || next.type === 'DBLCOLON')) continue; // a table-name qualifier, not a cell
|
||||
const tEnd = t.pos + t.value.length;
|
||||
const hit = collapsed ? lo >= t.pos && lo <= tEnd : t.pos < hi && tEnd > lo;
|
||||
if (hit) targets.push(t);
|
||||
}
|
||||
if (!targets.length) return null;
|
||||
|
||||
let out = '';
|
||||
let last = 0;
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
for (const tok of targets) {
|
||||
const { col, row, colAbs, rowAbs } = parseA1(tok.value);
|
||||
const [nColAbs, nRowAbs] = nextLock(colAbs, rowAbs);
|
||||
const rewritten = toA1(col, row, nColAbs, nRowAbs);
|
||||
out += body.slice(last, tok.pos) + rewritten;
|
||||
if (start === -1) start = out.length - rewritten.length;
|
||||
end = out.length;
|
||||
last = tok.pos + tok.value.length;
|
||||
}
|
||||
out += body.slice(last);
|
||||
// a collapsed caret in -> a collapsed caret out (after the one toggled
|
||||
// reference), same as before; a real selection in keeps spanning every
|
||||
// reference it touched, so a repeated F4 keeps cycling all of them.
|
||||
return collapsed
|
||||
? { text: '=' + out, start: 1 + end, end: 1 + end }
|
||||
: { text: '=' + out, start: 1 + start, end: 1 + end };
|
||||
}
|
||||
|
||||
/** A1 -> $A$1 -> A$1 -> $A1 -> A1 -> ... (the classic F4 cycle). */
|
||||
function nextLock(colAbs: boolean, rowAbs: boolean): [boolean, boolean] {
|
||||
if (!colAbs && !rowAbs) return [true, true];
|
||||
if (colAbs && rowAbs) return [false, true];
|
||||
if (!colAbs && rowAbs) return [true, false];
|
||||
return [false, false]; // colAbs && !rowAbs
|
||||
}
|
||||
|
||||
/**
|
||||
* If the caret sits inside (anywhere within, or right at the end of) a bare
|
||||
* identifier being typed in a formula -- not yet a resolved cell reference,
|
||||
* not the start of an already-open function call -- return the identifier's
|
||||
* text *up to the caret* and its start offset in `formula` (which still
|
||||
* includes the leading "="). Drives function-name autocomplete; `null`
|
||||
* means "nothing to suggest here" (not a formula at all, a cell reference
|
||||
* like "A1", the caret sitting in whitespace or right after a completed
|
||||
* token rather than still touching it, ...).
|
||||
*
|
||||
* Same shape as {@link cycleReferenceLocks} above: strip the leading "=",
|
||||
* tokenize, and inspect the token(s) around the caret -- except here only
|
||||
* the prefix up to the caret is tokenized (nothing after it is ever looked
|
||||
* at, same as how autocomplete works in most editors), so the "identifier"
|
||||
* found is always exactly what's to the *left* of the caret, regardless of
|
||||
* whether more of the same word continues to its right.
|
||||
*/
|
||||
export function currentIdentifierAtCaret(
|
||||
formula: string,
|
||||
caret: number,
|
||||
): { text: string; start: number } | null {
|
||||
if (!formula.startsWith('=')) return null;
|
||||
const pos = caret - 1; // caret position relative to body = formula.slice(1)
|
||||
if (pos < 0) return null;
|
||||
let toks: Token[];
|
||||
try {
|
||||
toks = tokenize(formula.slice(1, caret));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// tokenize() appends a trailing EOF marker -- skip it to get the last
|
||||
// *real* token.
|
||||
const real = toks.filter((t) => t.type !== 'EOF');
|
||||
const last = real[real.length - 1];
|
||||
if (!last || last.type !== 'IDENT') return null;
|
||||
// the token must reach all the way to the caret -- if it doesn't, the
|
||||
// caret has moved past it (trailing whitespace, e.g. "=SUM |") and
|
||||
// there's no word actively being typed right at the caret any more.
|
||||
if (last.pos + last.value.length !== pos) return null;
|
||||
if (looksLikeCell(last.value)) return null; // looks like a cell reference, not a function name
|
||||
return { text: last.value, start: last.pos + 1 };
|
||||
}
|
||||
|
||||
// ---- whole-column / whole-row references (`B:B`, `A:C`, `1:1`, `$2:$5`) ----
|
||||
|
||||
/** Stand-in for "the last row/column" in a whole-column/whole-row range's
|
||||
* open end. Every consumer clamps it to the actual table size (see
|
||||
* {@link rangeCells}); a consumer that merely clamps to its table (the
|
||||
* Name Box selection) gets the right answer for free. */
|
||||
export const WHOLE_LINE_END = 9_999_998;
|
||||
|
||||
export type LineAxis = 'col' | 'row';
|
||||
|
||||
const COL_PART_RE = /^(\$?)([A-Za-z]{1,3})$/;
|
||||
const ROW_PART_RE = /^(\$?)([0-9]{1,7})$/;
|
||||
|
||||
/** One side of a whole-line reference: `"B"`/`"$B"` (a column) or
|
||||
* `"3"`/`"$3"` (a 1-based row) -> its 0-based index, or `null`. */
|
||||
export function parseLinePart(text: string): { axis: LineAxis; index: number; abs: boolean } | null {
|
||||
let m = COL_PART_RE.exec(text);
|
||||
if (m) return { axis: 'col', index: colToNumber(m[2]), abs: m[1] === '$' };
|
||||
m = ROW_PART_RE.exec(text);
|
||||
if (m && Number(m[2]) >= 1) return { axis: 'row', index: Number(m[2]) - 1, abs: m[1] === '$' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatLinePart(axis: LineAxis, index: number, abs: boolean): string {
|
||||
return (abs ? '$' : '') + (axis === 'col' ? numberToCol(index) : String(index + 1));
|
||||
}
|
||||
|
||||
/** If `toks[i]`, `toks[i+1]`, `toks[i+2]` spell a whole-line range (`B:B`,
|
||||
* `A:C`, `1:1`, `$2:$5` -- both sides the same axis), return its parts.
|
||||
* Shared by the parser and every token-level formula rewriter (copy,
|
||||
* row/column insert/delete, table rename), so they all agree on the shape. */
|
||||
export function wholeLineAt(
|
||||
toks: Token[],
|
||||
i: number,
|
||||
): { axis: LineAxis; a: Token; b: Token; start: number; end: number; startAbs: boolean; endAbs: boolean } | null {
|
||||
const a = toks[i];
|
||||
const colon = toks[i + 1];
|
||||
const b = toks[i + 2];
|
||||
if (!a || !colon || !b || colon.type !== 'COLON') return null;
|
||||
if ((a.type !== 'IDENT' && a.type !== 'NUMBER') || (b.type !== 'IDENT' && b.type !== 'NUMBER')) return null;
|
||||
const pa = parseLinePart(a.value);
|
||||
const pb = parseLinePart(b.value);
|
||||
if (!pa || !pb || pa.axis !== pb.axis) return null;
|
||||
return { axis: pa.axis, a, b, start: pa.index, end: pb.index, startAbs: pa.abs, endAbs: pb.abs };
|
||||
}
|
||||
|
||||
/** The cells a range covers, with a whole-column/whole-row range's open
|
||||
* end clamped to its table's current size (`size` null = unknown table ->
|
||||
* no cells). An ordinary range is just {@link expandRange}. */
|
||||
export function rangeCells(
|
||||
r: { start: { col: number; row: number }; end: { col: number; row: number }; whole?: LineAxis },
|
||||
size: { cols: number; rows: number } | null,
|
||||
): Array<{ col: number; row: number }> {
|
||||
if (!r.whole) return expandRange(r.start, r.end);
|
||||
if (!size) return [];
|
||||
const clamp = (v: number, n: number) => Math.min(v, n - 1);
|
||||
const start = { col: Math.min(r.start.col, r.end.col), row: Math.min(r.start.row, r.end.row) };
|
||||
const end = { col: Math.max(r.start.col, r.end.col), row: Math.max(r.start.row, r.end.row) };
|
||||
// a whole line entirely beyond the table covers nothing
|
||||
if (r.whole === 'col' && start.col >= size.cols) return [];
|
||||
if (r.whole === 'row' && start.row >= size.rows) return [];
|
||||
return expandRange(start, { col: clamp(end.col, size.cols), row: clamp(end.row, size.rows) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a rectangular range (given by two corners, any order) into the list of
|
||||
* cell coordinates it covers, row-major.
|
||||
*/
|
||||
export function expandRange(
|
||||
a: { col: number; row: number },
|
||||
b: { col: number; row: number },
|
||||
): Array<{ col: number; row: number }> {
|
||||
const c0 = Math.min(a.col, b.col);
|
||||
const c1 = Math.max(a.col, b.col);
|
||||
const r0 = Math.min(a.row, b.row);
|
||||
const r1 = Math.max(a.row, b.row);
|
||||
const out: Array<{ col: number; row: number }> = [];
|
||||
for (let r = r0; r <= r1; r++) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
out.push({ col: c, row: r });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Shared types for the formula engine.
|
||||
*
|
||||
* The engine is intentionally framework-agnostic: it knows nothing about Vue,
|
||||
* the store or the DOM. The host application implements {@link EngineHost} to
|
||||
* expose the current document to the evaluator.
|
||||
*/
|
||||
|
||||
/** All error values the engine can produce. They propagate to dependent cells. */
|
||||
export type ErrorCode =
|
||||
| '#REF' // invalid / deleted reference or unknown sheet name
|
||||
| '#DIV/0!' // division by zero
|
||||
| '#NAME?' // unknown function
|
||||
| '#ERR' // parse error
|
||||
| '#CIRC' // circular reference
|
||||
| '#VALUE!' // wrong value type in arithmetic (documented extension)
|
||||
| '#N/A'; // XLOOKUP: no matching entry and no if_not_found given (documented extension)
|
||||
|
||||
export const ERROR_CODES: ErrorCode[] = [
|
||||
'#REF',
|
||||
'#DIV/0!',
|
||||
'#NAME?',
|
||||
'#ERR',
|
||||
'#CIRC',
|
||||
'#VALUE!',
|
||||
'#N/A',
|
||||
];
|
||||
|
||||
/** A formula error. Instances are immutable and comparable by `code`. */
|
||||
export class FormulaError {
|
||||
constructor(
|
||||
public readonly code: ErrorCode,
|
||||
public readonly detail?: string,
|
||||
) {}
|
||||
toString(): string {
|
||||
return this.code;
|
||||
}
|
||||
}
|
||||
|
||||
export function isError(v: unknown): v is FormulaError {
|
||||
return v instanceof FormulaError;
|
||||
}
|
||||
|
||||
/** A plain scalar a cell can hold once evaluated. `null` means "empty cell". */
|
||||
export type Scalar = number | string | null;
|
||||
|
||||
/** The fully evaluated value of a cell. */
|
||||
export type CellValue = Scalar | FormulaError;
|
||||
|
||||
/** 0-based cell coordinate inside a single table. */
|
||||
export interface Coord {
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
/** A cell address that also carries the id of the table it lives on. */
|
||||
export interface CellRef extends Coord {
|
||||
tableId: string;
|
||||
}
|
||||
|
||||
/** A document-defined name resolving to a fixed cell/range on a specific
|
||||
* table -- fed into {@link Workbook} (see workbook.ts) so it can implement
|
||||
* {@link EngineHost.resolveNamedRange}. Unlike a custom function, there is
|
||||
* no expression/formula of its own, just fixed coordinates -- so there is
|
||||
* nothing that could cyclically reference itself. `tableId` (not a table
|
||||
* *name*) so a named range survives its target table being renamed; the
|
||||
* host resolves it to that table's *current* name on lookup, the same
|
||||
* qualifier a formula's own `Tabelle!Zelle` reference would carry. */
|
||||
export interface NamedRangeDef {
|
||||
id: string;
|
||||
name: string;
|
||||
tableId: string;
|
||||
start: Coord;
|
||||
end: Coord;
|
||||
}
|
||||
|
||||
/** Stable string key for a cell across the whole document. */
|
||||
export function keyOf(tableId: string, col: number, row: number): string {
|
||||
return `${tableId}␟${col}␟${row}`;
|
||||
}
|
||||
|
||||
export function parseKey(key: string): CellRef {
|
||||
const [tableId, col, row] = key.split('␟');
|
||||
return { tableId, col: Number(col), row: Number(row) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The document as seen by the engine. Implemented by the Vue store.
|
||||
*
|
||||
* The addressable unit is a **table** (a table object on a Numbers-style
|
||||
* canvas). Table names are only unique *per sheet* -- two different sheets
|
||||
* may each hold a table called "Umsatz". An unqualified table reference
|
||||
* (`Umsatz!B2` / `Umsatz::B2`, no sheet given) resolves to whichever table
|
||||
* named "Umsatz" lives on the *same sheet* as the formula doing the
|
||||
* referencing, falling back to the single document-wide match when the name
|
||||
* isn't ambiguous there. The explicit 3-part form (`Analyse!Umsatz::B2`)
|
||||
* disambiguates when it would otherwise be genuinely ambiguous (name used on
|
||||
* two *other* sheets, neither of them the formula's own).
|
||||
*/
|
||||
export interface EngineHost {
|
||||
/** Id of the table the formula currently being evaluated lives on. */
|
||||
homeTableId: string;
|
||||
/**
|
||||
* Resolve a table *name* (as written in a formula) to its id, optionally
|
||||
* narrowed to a specific *sheet* name (the "Blatt!" part of a 3-part
|
||||
* `Blatt!Tabelle::Zelle` reference). Matching is case-insensitive and
|
||||
* tolerant of surrounding whitespace.
|
||||
*
|
||||
* - `sheetName` given: only a table with that name **on that sheet**
|
||||
* matches; otherwise `null` (including when the sheet itself is unknown).
|
||||
* - `sheetName` omitted/`null`: matches a table by name anywhere in the
|
||||
* document. Exactly one match resolves as before. Multiple matches (the
|
||||
* name exists on more than one sheet) prefer a table on the same sheet
|
||||
* as {@link homeTableId}; if that still doesn't narrow it to one, `null`
|
||||
* (`#REF` -- the formula needs an explicit sheet qualifier).
|
||||
*/
|
||||
resolveTableName(name: string, sheetName?: string | null): string | null;
|
||||
/**
|
||||
* Resolve a *named range* (as written, unqualified, in a formula -- e.g.
|
||||
* `Umsatz_Q1`) to the table-qualified cell/range it currently stands for.
|
||||
* Matching is case-insensitive and tolerant of surrounding whitespace,
|
||||
* same as {@link resolveTableName}. `null` when the name is unknown (or
|
||||
* its target table has since been deleted) -- the caller turns that into
|
||||
* `#NAME?`, the same error class an unknown function name gets.
|
||||
*
|
||||
* Resolved by *name*, live, every time -- never baked into the AST -- so a
|
||||
* named range survives its target table being renamed, the same way a
|
||||
* `Tabelle!Zelle` reference's table name is looked up fresh on every
|
||||
* evaluation rather than resolved once at parse time.
|
||||
*/
|
||||
resolveNamedRange(
|
||||
name: string,
|
||||
): { qualifier: string; sheetQualifier?: string; start: Coord; end: Coord } | null;
|
||||
/** Dimensions of a table, used for range clamping and #REF detection. */
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null;
|
||||
/**
|
||||
* Already-computed value of another cell. The recalculation driver guarantees
|
||||
* precedents are evaluated first (topological order), so this never triggers
|
||||
* a nested evaluation.
|
||||
*/
|
||||
getComputed(tableId: string, col: number, row: number): CellValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* The document's WRITE surface, as seen by a running macro (see
|
||||
* evaluator.ts's `runMacroActions`/`execAction` and engine/README.md's
|
||||
* "Makros" section). Deliberately separate from {@link EngineHost} -- that
|
||||
* interface stays pure/read-only, implemented by `Workbook` alone, used by
|
||||
* every ordinary formula evaluation; `MacroWriteHost` is only ever
|
||||
* implemented by the store (`document.ts#runMacro`), only ever passed to
|
||||
* the interpreter for an explicitly-triggered macro run, and never reaches
|
||||
* the passive recalculation pass at all -- a macro's write is
|
||||
* indistinguishable, from the engine's point of view, from the same edit
|
||||
* made by hand through the UI.
|
||||
*
|
||||
* Every table/sheet argument below is a plain qualifier STRING (e.g.
|
||||
* `"Umsatz"` or `"Blatt2!Kosten"`, split on the first `!`) rather than a
|
||||
* resolved id or a dedicated reference AST node -- resolved fresh, by the
|
||||
* implementation, exactly like a formula's own `Tabelle!Zelle` qualifier
|
||||
* is resolved fresh on every read (never cached), so a macro that renames
|
||||
* or adds a table earlier in its own run can address it by its new name
|
||||
* later in the same run.
|
||||
*/
|
||||
export interface MacroWriteHost {
|
||||
/** Write the same raw cell text (auto-detected as a formula if it starts
|
||||
* with "=", exactly like typing it manually) into every given cell --
|
||||
* one cell for a `SETVALUE` on a single reference, several for a range
|
||||
* target (broadcasting the same value, Excel's Ctrl+Enter semantics). */
|
||||
setValue(cells: CellRef[], text: string): void;
|
||||
insertRow(tableRef: string, at: number): void;
|
||||
deleteRow(tableRef: string, at: number): void;
|
||||
insertCol(tableRef: string, at: number): void;
|
||||
deleteCol(tableRef: string, at: number): void;
|
||||
/** `sheetRef` is a plain sheet name (not table-qualified). */
|
||||
addTable(sheetRef: string, name: string): void;
|
||||
renameTable(tableRef: string, newName: string): void;
|
||||
deleteTable(tableRef: string): void;
|
||||
addSheet(name: string): void;
|
||||
renameSheet(sheetRef: string, newName: string): void;
|
||||
deleteSheet(sheetRef: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The document's NETWORK surface, as seen by a running macro's `GET`/`POST`
|
||||
* assignment form (`x = GET(url)` / `x = POST(url; body)`; see evaluator.
|
||||
* ts's `execStatementAsync`). Kept separate from {@link MacroWriteHost} for
|
||||
* the same reason that one is kept separate from {@link EngineHost}: this
|
||||
* engine module stays framework/DOM-agnostic (no `fetch`, no browser APIs
|
||||
* anywhere in `src/engine/`) -- the store (`document.ts#runMacro`) is the
|
||||
* only implementer, and it's the one place a network confirmation prompt
|
||||
* and the actual `fetch()` call belong.
|
||||
*
|
||||
* A single `request()` method (not separate `get`/`post` methods) because
|
||||
* both share the exact same "confirm this host once, then fetch and parse
|
||||
* JSON" flow in the implementation -- only the HTTP method and whether a
|
||||
* body is sent differ.
|
||||
*/
|
||||
export interface MacroNetworkHost {
|
||||
/** Resolves with the parsed JSON response body, or rejects with a plain
|
||||
* `Error` (network failure, a non-2xx status, a response that isn't
|
||||
* valid JSON, or the user declining a host-confirmation prompt) --
|
||||
* caught the same way as any other macro action failure, aborting the
|
||||
* run and surfacing the message as the result's `.message`. */
|
||||
request(method: 'GET' | 'POST', url: string, body?: string): Promise<unknown>;
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* User-defined functions: a document-level library of named, parameterised
|
||||
* formulas (`MARGE(revenue; cost) = (revenue - cost) / revenue`) callable
|
||||
* from any cell like a built-in (`=MARGE(Umsatz!B2; Umsatz!C2)`).
|
||||
*
|
||||
* The evaluator (`evaluator.ts`) evaluates function arguments *eagerly* --
|
||||
* scalar `CellValue`s in, `CellValue` out (`FunctionFn` in `functions.ts`) --
|
||||
* which has no room for a "body formula with its own cell references and a
|
||||
* parameter environment". Rather than rework the evaluator, a `kind:
|
||||
* 'expression'` call (a single-formula body) is *inlined* into the caller's
|
||||
* AST at parse time, before the graph/evaluator ever see it: the `FuncNode`
|
||||
* is replaced by a (recursively expanded) copy of the function body, with
|
||||
* every `ParamNode` swapped for the caller's own argument subtree. After
|
||||
* that, the stored AST looks exactly as if the user had written the
|
||||
* expanded formula by hand -- `collectRefs`/`precedentsOf`/`evaluate` all
|
||||
* work completely unmodified, and correctly pick up cell references that
|
||||
* live *only* inside the function body, not just the ones passed as
|
||||
* arguments.
|
||||
*
|
||||
* A `kind: 'block'` call (a FOR/IF/RETURN "code" body, see parser.ts's
|
||||
* `parseFunctionBlock`) is different: this module only resolves *nested*
|
||||
* function calls inside it (`expandBlock`, below) -- still at parse time,
|
||||
* still the same "inside-out" inlining -- and wraps the result in a
|
||||
* `BlockCallNode`. The block's *own* control flow (FOR iteration, IF
|
||||
* branching, an early RETURN) is only resolved at evaluation time, by a
|
||||
* small interpreter in `evaluator.ts`, since it genuinely depends on real
|
||||
* cell values. See src/engine/README.md's "Schleifen" section for the full
|
||||
* design and why that split is necessary.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseFormula,
|
||||
parseFunctionBlock,
|
||||
type ActionNode,
|
||||
type BlockNode,
|
||||
type ForNode,
|
||||
type Node,
|
||||
} from './parser';
|
||||
|
||||
export interface FunctionDef {
|
||||
id: string;
|
||||
/** document-unique, matched case-insensitively (like built-in names). */
|
||||
name: string;
|
||||
/** parameter names, in call order. */
|
||||
params: string[];
|
||||
/** raw formula text, no leading "=" -- a single expression for
|
||||
* `kind: 'expression'`, a statement sequence (assignments, FOR/DO/END,
|
||||
* IF/THEN/ELSE/END, RETURN anywhere) for `kind: 'block'`. */
|
||||
body: string;
|
||||
/** 'expression' (the original, Phase-1 form) or 'block' (FOR/IF/RETURN
|
||||
* "code"). Every function predating this field is 'expression'. */
|
||||
kind: 'expression' | 'block';
|
||||
}
|
||||
|
||||
/** Recursion guard for `expandUserFunctions` -- a cycle should always be
|
||||
* rejected at definition time (see the store's cycle check), this is only
|
||||
* a last-resort safety net so a bug there degrades to a cell error rather
|
||||
* than a stack overflow. */
|
||||
const MAX_EXPANSION_DEPTH = 32;
|
||||
|
||||
export function buildFunctionIndex(defs: readonly FunctionDef[]): Map<string, FunctionDef> {
|
||||
return new Map(defs.map((d) => [d.name.toUpperCase(), d]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every call to a user-defined function in `ast` with either a
|
||||
* (recursively expanded, parameter-substituted) copy of its body
|
||||
* (`kind: 'expression'`), or a `BlockCallNode` wrapping its (nested-calls-
|
||||
* resolved) block for evaluator.ts's interpreter to run (`kind: 'block'`).
|
||||
* Calls to built-in or genuinely unknown names are left untouched --
|
||||
* `evaluate()`'s existing `#NAME?` path already covers "unknown function",
|
||||
* built-in or not.
|
||||
*
|
||||
* Throws a plain `Error` (not `ParseError` -- there's nothing to parse here,
|
||||
* just structural problems: wrong argument count, or a definition cycle
|
||||
* `MAX_EXPANSION_DEPTH` didn't catch at save time) on failure; the caller
|
||||
* (`Workbook.parseCell`) wraps this the same way it already wraps
|
||||
* `parseFormula` errors, turning it into a `#ERR` cell value.
|
||||
*/
|
||||
export function expandUserFunctions(ast: Node, index: ReadonlyMap<string, FunctionDef>): Node {
|
||||
if (index.size === 0) return ast; // fast path: no user functions defined at all
|
||||
return expand(ast, index, new Map(), new Map(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link expandUserFunctions}, for a macro body (`parseMacroBlock()`'s
|
||||
* output) instead of an ordinary formula/function AST -- resolves any
|
||||
* custom-function call reachable from a macro's expressions (e.g.
|
||||
* `SETVALUE(Umsatz::A1; MARGE(Umsatz::B1; Umsatz::C1))`) the exact same way
|
||||
* a formula would, via the already-existing `expandBlock`. The store calls
|
||||
* this once per `runMacro()` before handing the block to
|
||||
* evaluator.ts's `runMacroActions` -- a nested custom-function call can
|
||||
* therefore compute a value inside a macro, but (since it's always parsed
|
||||
* with `allowWrites: false`) can never itself contain an `ActionNode`, so
|
||||
* it can never perform a write.
|
||||
*/
|
||||
export function expandMacroBlock(block: BlockNode, index: ReadonlyMap<string, FunctionDef>): BlockNode {
|
||||
if (index.size === 0) return block; // same fast path as expandUserFunctions
|
||||
return expandBlock(block, index, new Map(), new Map(), 0);
|
||||
}
|
||||
|
||||
function expand(
|
||||
node: Node,
|
||||
index: ReadonlyMap<string, FunctionDef>,
|
||||
bodyCache: Map<string, Node>,
|
||||
blockCache: Map<string, BlockNode>,
|
||||
depth: number,
|
||||
): Node {
|
||||
switch (node.kind) {
|
||||
case 'number':
|
||||
case 'string':
|
||||
case 'errorliteral':
|
||||
case 'cell':
|
||||
case 'range':
|
||||
case 'namedref':
|
||||
case 'param':
|
||||
case 'omitted':
|
||||
case 'json':
|
||||
return node; // leaves -- nothing to expand
|
||||
case 'unary':
|
||||
return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) };
|
||||
case 'binary':
|
||||
return {
|
||||
...node,
|
||||
left: expand(node.left, index, bodyCache, blockCache, depth),
|
||||
right: expand(node.right, index, bodyCache, blockCache, depth),
|
||||
};
|
||||
case 'blockcall':
|
||||
// never present in freshly-parsed input -- only expand() itself ever
|
||||
// produces one (see the 'func' case below) -- but the type checker
|
||||
// doesn't know that, so: a defensive leaf, nothing to do.
|
||||
return node;
|
||||
case 'func': {
|
||||
// expand the arguments regardless of whether this call is itself a
|
||||
// user function -- a built-in like SUM(...) may still take a
|
||||
// user-function call as one of its arguments.
|
||||
const args = node.args.map((a) => expand(a, index, bodyCache, blockCache, depth));
|
||||
const def = index.get(node.name.toUpperCase());
|
||||
if (!def) return { ...node, args };
|
||||
|
||||
if (depth >= MAX_EXPANSION_DEPTH) {
|
||||
throw new Error(`'${def.name}' expansion too deep (likely a definition cycle)`);
|
||||
}
|
||||
if (args.length !== def.params.length) {
|
||||
throw new Error(
|
||||
`'${def.name}' expects ${def.params.length} argument(s), got ${args.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (def.kind === 'block') {
|
||||
let block = blockCache.get(def.id);
|
||||
if (!block) {
|
||||
block = parseFunctionBlock(def.body, def.params); // ParseError propagates -- caller wraps it
|
||||
blockCache.set(def.id, block);
|
||||
}
|
||||
// resolve any *other* function calls the block contains (built-ins
|
||||
// untouched, other user functions inlined/turned into their own
|
||||
// BlockCallNode) -- same "inside-out" principle as the
|
||||
// expression-kind path below. This function's *own* parameters
|
||||
// stay as ParamNode inside `block`; evaluator.ts's interpreter
|
||||
// binds them from `args` at evaluation time, since a FOR loop's
|
||||
// iteration count and an IF's branch both depend on real cell
|
||||
// values that aren't known yet here.
|
||||
const expandedBlock = expandBlock(block, index, bodyCache, blockCache, depth + 1);
|
||||
return { kind: 'blockcall', block: expandedBlock, args, paramNames: def.params, label: def.name };
|
||||
}
|
||||
|
||||
let body = bodyCache.get(def.id);
|
||||
if (!body) {
|
||||
body = parseFormula(def.body, def.params); // ParseError propagates -- caller wraps it
|
||||
bodyCache.set(def.id, body);
|
||||
}
|
||||
// expand any calls *inside* the body first (it may call other user
|
||||
// functions -- possibly reusing the same parameter names as this one;
|
||||
// resolving inside-out like this keeps each function's own parameters
|
||||
// scoped correctly no matter what the caller's happen to be named),
|
||||
// then substitute this function's own parameters last.
|
||||
const expandedBody = expand(body, index, bodyCache, blockCache, depth + 1);
|
||||
const bound = new Map(def.params.map((p, i) => [p.toUpperCase(), args[i]]));
|
||||
return substitute(expandedBody, bound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every `ParamNode` in `node` matching a name in `bound` with its
|
||||
* bound subtree. Exported for evaluator.ts's block interpreter, which reuses
|
||||
* this exact mechanism to resolve names (parameters *and* local variables --
|
||||
* it doesn't distinguish the two, both are just entries in its environment
|
||||
* map) against the current execution state before evaluating an expression.
|
||||
*/
|
||||
export function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
|
||||
switch (node.kind) {
|
||||
case 'param':
|
||||
// always found in practice (arity is checked before this runs, and
|
||||
// parameter names are exactly what the body was parsed against) --
|
||||
// the fallback just leaves an unresolved param for evaluate()'s own
|
||||
// defensive #ERR case rather than assuming it can't happen.
|
||||
return bound.get(node.name.toUpperCase()) ?? node;
|
||||
case 'number':
|
||||
case 'string':
|
||||
case 'errorliteral':
|
||||
case 'cell':
|
||||
case 'range':
|
||||
case 'namedref':
|
||||
case 'omitted':
|
||||
case 'json':
|
||||
return node;
|
||||
case 'unary':
|
||||
return { ...node, operand: substitute(node.operand, bound) };
|
||||
case 'binary':
|
||||
return { ...node, left: substitute(node.left, bound), right: substitute(node.right, bound) };
|
||||
case 'func':
|
||||
return { ...node, args: node.args.map((a) => substitute(a, bound)) };
|
||||
case 'blockcall':
|
||||
// only `.args` can still reference an *outer* scope's parameter (e.g.
|
||||
// this call sits inside another function's own body, referencing
|
||||
// *that* function's parameter) -- `.block` itself only refers to this
|
||||
// call's *own* parameters, a separate scope bound later, at
|
||||
// evaluation time, so it's left untouched here.
|
||||
return { ...node, args: node.args.map((a) => substitute(a, bound)) };
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link expand}, run over every leaf expression a `BlockNode` contains
|
||||
* (each statement's value/condition/source, recursing into nested FOR/IF
|
||||
* bodies) instead of over a single `Node` -- resolves nested function
|
||||
* calls throughout a block-kind function's body before it's wrapped in a
|
||||
* `BlockCallNode`. */
|
||||
function expandBlock(
|
||||
block: BlockNode,
|
||||
index: ReadonlyMap<string, FunctionDef>,
|
||||
bodyCache: Map<string, Node>,
|
||||
blockCache: Map<string, BlockNode>,
|
||||
depth: number,
|
||||
): BlockNode {
|
||||
return {
|
||||
kind: 'block',
|
||||
statements: block.statements.map((stmt) => {
|
||||
switch (stmt.kind) {
|
||||
case 'assign':
|
||||
return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
|
||||
case 'return':
|
||||
return { ...stmt, value: expand(stmt.value, index, bodyCache, blockCache, depth) };
|
||||
case 'for':
|
||||
return {
|
||||
...stmt,
|
||||
source: expand(stmt.source, index, bodyCache, blockCache, depth),
|
||||
body: expandBlock(stmt.body, index, bodyCache, blockCache, depth),
|
||||
};
|
||||
case 'ifstmt':
|
||||
return {
|
||||
...stmt,
|
||||
cond: expand(stmt.cond, index, bodyCache, blockCache, depth),
|
||||
then: expandBlock(stmt.then, index, bodyCache, blockCache, depth),
|
||||
else: stmt.else ? expandBlock(stmt.else, index, bodyCache, blockCache, depth) : null,
|
||||
};
|
||||
case 'action':
|
||||
return { ...stmt, args: stmt.args.map((a) => expand(a, index, bodyCache, blockCache, depth)) };
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct function name called anywhere in `ast` (built-in or not --
|
||||
* callers filter against their own registry). Used by the store to build a
|
||||
* definition-time call graph between user functions for cycle detection,
|
||||
* without a second AST-walking implementation alongside `collectRefs`.
|
||||
*/
|
||||
export function collectCalledFunctionNames(ast: Node, out: Set<string> = new Set()): Set<string> {
|
||||
switch (ast.kind) {
|
||||
case 'func':
|
||||
out.add(ast.name.toUpperCase());
|
||||
ast.args.forEach((a) => collectCalledFunctionNames(a, out));
|
||||
break;
|
||||
case 'binary':
|
||||
collectCalledFunctionNames(ast.left, out);
|
||||
collectCalledFunctionNames(ast.right, out);
|
||||
break;
|
||||
case 'unary':
|
||||
collectCalledFunctionNames(ast.operand, out);
|
||||
break;
|
||||
default:
|
||||
break; // number/string/cell/range/param/blockcall -- no nested calls
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every name a block declares -- an assignment target or a loop variable --
|
||||
* in the order first declared, recursing into nested FOR/IF bodies. Used
|
||||
* by the store to validate these local names the same way it validates
|
||||
* parameters (not cell-shaped, no collision with a parameter, not a
|
||||
* reserved word); the engine itself has no opinion on naming policy or
|
||||
* German error text, so it only exposes the raw names here. The *same*
|
||||
* name legitimately reappears (an accumulator re-assigned inside a loop,
|
||||
* or across two separate loops) -- that's not the store's concern either,
|
||||
* it's intentional under the flat-scope design (see parser.ts). */
|
||||
export function declaredBlockNames(block: BlockNode, out: string[] = []): string[] {
|
||||
for (const stmt of block.statements) {
|
||||
switch (stmt.kind) {
|
||||
case 'assign':
|
||||
out.push(stmt.name);
|
||||
break;
|
||||
case 'for':
|
||||
out.push(stmt.varName);
|
||||
declaredBlockNames(stmt.body, out);
|
||||
break;
|
||||
case 'ifstmt':
|
||||
declaredBlockNames(stmt.then, out);
|
||||
if (stmt.else) declaredBlockNames(stmt.else, out);
|
||||
break;
|
||||
case 'return':
|
||||
case 'action':
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The first FOR loop, anywhere in the block (including nested inside
|
||||
* another FOR/IF), whose source isn't (syntactically) a parameter, a
|
||||
* single cell, a range, or a named-range reference -- i.e. can never
|
||||
* resolve to a concrete list of cells no matter what the caller passes.
|
||||
* Checked at save time so this is caught immediately, not just on the
|
||||
* first real call; the evaluator's
|
||||
* interpreter re-checks the same thing on the *substituted* source at call
|
||||
* time regardless (needed either way, since a parameter's shape isn't
|
||||
* known until then) as the actual enforcement. */
|
||||
export function findInvalidForSource(block: BlockNode): ForNode | null {
|
||||
for (const stmt of block.statements) {
|
||||
if (stmt.kind === 'for') {
|
||||
if (!isReferenceShaped(stmt.source) && !isJsonGetCall(stmt.source)) return stmt;
|
||||
const nested = findInvalidForSource(stmt.body);
|
||||
if (nested) return nested;
|
||||
} else if (stmt.kind === 'ifstmt') {
|
||||
const nested = findInvalidForSource(stmt.then) ?? (stmt.else && findInvalidForSource(stmt.else));
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Shared by {@link findInvalidForSource} and {@link findInvalidActionTarget}:
|
||||
* can this node, once parameters/locals are substituted at call time,
|
||||
* possibly resolve to a cell/range address at all? (A computed value --
|
||||
* `2+2`, a function call, ... -- never can, no matter what's substituted
|
||||
* in.) */
|
||||
function isReferenceShaped(node: Node): boolean {
|
||||
return node.kind === 'param' || node.kind === 'cell' || node.kind === 'range' || node.kind === 'namedref';
|
||||
}
|
||||
|
||||
/** A `FOR` source may also be a `JSONGET(...)` call -- once substituted and
|
||||
* evaluated it may resolve to a JSON array (see evaluator.ts's
|
||||
* `execStatementAsync` `'for'` case), a possibility `isReferenceShaped`
|
||||
* above doesn't cover since it's a genuinely new FOR-source *kind*, not a
|
||||
* reference. Not shared with `findInvalidActionTarget`'s SETVALUE-target
|
||||
* check -- a JSONGET result is a VALUE, never a place to write to, so a
|
||||
* write target must stay reference-shaped only. */
|
||||
function isJsonGetCall(node: Node): boolean {
|
||||
return node.kind === 'func' && node.name.toUpperCase() === 'JSONGET';
|
||||
}
|
||||
|
||||
/** {@link findInvalidForSource}'s counterpart for a macro's `SETVALUE`
|
||||
* target (its first argument) instead of a FOR source -- same reasoning,
|
||||
* checked at save time by store/document.ts#validateMacro. */
|
||||
export function findInvalidActionTarget(block: BlockNode): ActionNode | null {
|
||||
for (const stmt of block.statements) {
|
||||
if (stmt.kind === 'action') {
|
||||
if (stmt.name.toUpperCase() === 'SETVALUE' && stmt.args.length > 0 && !isReferenceShaped(stmt.args[0])) {
|
||||
return stmt;
|
||||
}
|
||||
} else if (stmt.kind === 'for') {
|
||||
const nested = findInvalidActionTarget(stmt.body);
|
||||
if (nested) return nested;
|
||||
} else if (stmt.kind === 'ifstmt') {
|
||||
const nested = findInvalidActionTarget(stmt.then) ?? (stmt.else && findInvalidActionTarget(stmt.else));
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** {@link collectCalledFunctionNames}, for a `kind: 'block'` body: runs it
|
||||
* over every leaf expression a `BlockNode` contains (each statement's
|
||||
* value/condition/source, recursing into nested FOR/IF bodies) and merges
|
||||
* the results -- `collectCalledFunctionNames` itself needs no changes,
|
||||
* since a block is never part of the `Node` union it switches on. */
|
||||
export function collectCalledFunctionNamesInBlock(
|
||||
block: BlockNode,
|
||||
out: Set<string> = new Set(),
|
||||
): Set<string> {
|
||||
for (const stmt of block.statements) {
|
||||
switch (stmt.kind) {
|
||||
case 'assign':
|
||||
case 'return':
|
||||
collectCalledFunctionNames(stmt.value, out);
|
||||
break;
|
||||
case 'for':
|
||||
collectCalledFunctionNames(stmt.source, out);
|
||||
collectCalledFunctionNamesInBlock(stmt.body, out);
|
||||
break;
|
||||
case 'ifstmt':
|
||||
collectCalledFunctionNames(stmt.cond, out);
|
||||
collectCalledFunctionNamesInBlock(stmt.then, out);
|
||||
if (stmt.else) collectCalledFunctionNamesInBlock(stmt.else, out);
|
||||
break;
|
||||
case 'action':
|
||||
stmt.args.forEach((a) => collectCalledFunctionNames(a, out));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative static check: does *every* execution path through `block`
|
||||
* reach a `return`? True if the block contains a `return` statement
|
||||
* (statements after it are unreachable but harmless), or an `IF` with an
|
||||
* `ELSE` where *both* branches always return. A `FOR` loop is never enough
|
||||
* on its own -- even if its body always returns when it runs, the loop
|
||||
* might cover zero cells at call time, which this static check can't see
|
||||
* (the interpreter's own "ended without RETURN" -> `#ERR` is the actual,
|
||||
* always-correct enforcement; this is only an early, save-time warning).
|
||||
*/
|
||||
export function blockAlwaysReturns(block: BlockNode): boolean {
|
||||
return block.statements.some((stmt) => {
|
||||
if (stmt.kind === 'return') return true;
|
||||
if (stmt.kind === 'ifstmt' && stmt.else) {
|
||||
return blockAlwaysReturns(stmt.then) && blockAlwaysReturns(stmt.else);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Framework-agnostic spreadsheet model that ties the engine modules together.
|
||||
*
|
||||
* The addressable unit is a **table** (a table object on a Numbers-style
|
||||
* canvas). A `Workbook` owns a flat list of tables and, across all of them:
|
||||
* - the raw text of every non-empty cell,
|
||||
* - the parsed AST of every formula cell (or an `#ERR` marker),
|
||||
* - the computed value of every cell,
|
||||
* - the document-wide {@link DependencyGraph}.
|
||||
*
|
||||
* It implements {@link EngineHost} so the evaluator can read neighbouring cell
|
||||
* values through it. The Vue store wraps an instance of this class in `reactive`
|
||||
* and mirrors its computed values into a reactive map, which makes every formula
|
||||
* cell behave like a `computed` value in the UI.
|
||||
*
|
||||
* Recalculation is incremental: `setCell` reparses one cell, updates its graph
|
||||
* edges and recomputes only the affected sub-graph in topological order.
|
||||
* Structural edits (rename / delete table, add rows, ...) call `rebuild()` for a
|
||||
* full, still-cheap pass.
|
||||
*/
|
||||
|
||||
import { DependencyGraph } from './graph';
|
||||
import { collectRefs, parseFormula, type Node } from './parser';
|
||||
import { rangeCells } from './refs';
|
||||
import { parseDateTimeLiteral } from './dates';
|
||||
import { buildFunctionIndex, expandUserFunctions, type FunctionDef } from './userFunctions';
|
||||
import {
|
||||
FormulaError,
|
||||
isError,
|
||||
keyOf,
|
||||
parseKey,
|
||||
type CellRef,
|
||||
type CellValue,
|
||||
type EngineHost,
|
||||
type NamedRangeDef,
|
||||
} from './types';
|
||||
import { evaluate } from './evaluator';
|
||||
|
||||
export interface TableModel {
|
||||
id: string;
|
||||
/** table name, used as the qualifier in formulas -- unique *per sheet*
|
||||
* only; the same name may occur on more than one sheet (see
|
||||
* `sheetName` and {@link EngineHost.resolveTableName}). */
|
||||
name: string;
|
||||
/** name of the sheet this table lives on. */
|
||||
sheetName: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
/** key `${col},${row}` -> raw user input. Absent = empty cell. */
|
||||
inputs: Map<string, string>;
|
||||
}
|
||||
|
||||
const NUMBER_RE = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
||||
|
||||
function localKey(col: number, row: number): string {
|
||||
return `${col},${row}`;
|
||||
}
|
||||
|
||||
/** Interpret a raw literal (non-formula) cell input. A recognized date/time
|
||||
* (see dates.ts's parseDateTimeLiteral) becomes its serial number -- dates
|
||||
* are just numbers in this engine, so `+`/`-`/comparisons work on them for
|
||||
* free. Checked before NUMBER_RE; the two never collide (dates use `-`/`.`
|
||||
* multi-part separators or `:`, which NUMBER_RE's single-decimal-point
|
||||
* shape never matches). */
|
||||
export function literalValue(raw: string): CellValue {
|
||||
const t = raw.trim();
|
||||
if (t === '') return null;
|
||||
const dt = parseDateTimeLiteral(t);
|
||||
if (dt) return dt.serial;
|
||||
if (NUMBER_RE.test(t)) return Number(t);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export class Workbook implements EngineHost {
|
||||
tables: TableModel[];
|
||||
/** transiently set to the home table while a formula is being evaluated */
|
||||
homeTableId = '';
|
||||
|
||||
private readonly graph = new DependencyGraph();
|
||||
/** cellKey -> parsed formula AST, or an error marker for un-parsable input */
|
||||
private readonly asts = new Map<string, Node | FormulaError>();
|
||||
/** cellKey -> computed value */
|
||||
private readonly values = new Map<string, CellValue>();
|
||||
/** user-defined function library for this document, name.toUpperCase() -> def. */
|
||||
private readonly functionIndex: Map<string, FunctionDef>;
|
||||
/** document-wide named ranges (see NamedRangeDef's doc comment). */
|
||||
private readonly namedRanges: NamedRangeDef[];
|
||||
|
||||
constructor(
|
||||
tables: TableModel[],
|
||||
functionDefs: FunctionDef[] = [],
|
||||
namedRanges: NamedRangeDef[] = [],
|
||||
) {
|
||||
this.tables = tables;
|
||||
this.functionIndex = buildFunctionIndex(functionDefs);
|
||||
this.namedRanges = namedRanges;
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
// ---- EngineHost ---------------------------------------------------------
|
||||
|
||||
resolveTableName(name: string, sheetName?: string | null): string | null {
|
||||
const needle = name.trim().toLowerCase();
|
||||
const candidates = this.tables.filter((t) => t.name.trim().toLowerCase() === needle);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
if (sheetName != null) {
|
||||
const sheetNeedle = sheetName.trim().toLowerCase();
|
||||
const hit = candidates.find((t) => t.sheetName.trim().toLowerCase() === sheetNeedle);
|
||||
return hit ? hit.id : null;
|
||||
}
|
||||
if (candidates.length === 1) return candidates[0].id;
|
||||
|
||||
// ambiguous (same name on more than one sheet) -- prefer a table on the
|
||||
// same sheet as the formula doing the referencing, if there is one
|
||||
const home = this.tableById(this.homeTableId);
|
||||
if (home) {
|
||||
const sameSheet = candidates.find((t) => t.sheetName === home.sheetName);
|
||||
if (sameSheet) return sameSheet.id;
|
||||
}
|
||||
return null; // still ambiguous -> #REF, needs an explicit sheet qualifier
|
||||
}
|
||||
|
||||
resolveNamedRange(
|
||||
name: string,
|
||||
): { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } } | null {
|
||||
const needle = name.trim().toLowerCase();
|
||||
const def = this.namedRanges.find((d) => d.name.trim().toLowerCase() === needle);
|
||||
if (!def) return null;
|
||||
const table = this.tableById(def.tableId);
|
||||
if (!table) return null; // target table was since deleted -- #NAME?, same as a stale table rename
|
||||
return { qualifier: table.name, sheetQualifier: table.sheetName, start: def.start, end: def.end };
|
||||
}
|
||||
|
||||
tableSize(tableId: string): { cols: number; rows: number } | null {
|
||||
const t = this.tableById(tableId);
|
||||
return t ? { cols: t.cols, rows: t.rows } : null;
|
||||
}
|
||||
|
||||
getComputed(tableId: string, col: number, row: number): CellValue {
|
||||
if (col < 0 || row < 0) return new FormulaError('#REF');
|
||||
const v = this.values.get(keyOf(tableId, col, row));
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
// ---- reads ------------------------------------------------------------
|
||||
|
||||
tableById(id: string): TableModel | undefined {
|
||||
return this.tables.find((t) => t.id === id);
|
||||
}
|
||||
|
||||
getInput(tableId: string, col: number, row: number): string {
|
||||
return this.tableById(tableId)?.inputs.get(localKey(col, row)) ?? '';
|
||||
}
|
||||
|
||||
/** Public computed-value read used by the store / UI. */
|
||||
cellValue(tableId: string, col: number, row: number): CellValue {
|
||||
const v = this.values.get(keyOf(tableId, col, row));
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
/** Direct precedents of a cell -- the cells its own formula reads (a
|
||||
* formula's `#REF`/named-range/etc. resolution already baked into the
|
||||
* graph edges, see `precedentsOf()` above). Public wrapper around the
|
||||
* internal `DependencyGraph`, for UI features (the dependency-graph
|
||||
* view) that need to *query* the graph -- evaluation itself never calls
|
||||
* this, it only calls `setPrecedents`/`affected`/`topoOrder`. */
|
||||
graphPrecedents(tableId: string, col: number, row: number): CellRef[] {
|
||||
return [...this.graph.getPrecedents(keyOf(tableId, col, row))].map(parseKey);
|
||||
}
|
||||
/** Direct dependents of a cell -- every cell whose formula reads *this*
|
||||
* one. See graphPrecedents() above. */
|
||||
graphDependents(tableId: string, col: number, row: number): CellRef[] {
|
||||
return [...this.graph.getDependents(keyOf(tableId, col, row))].map(parseKey);
|
||||
}
|
||||
|
||||
// ---- writes ---------------------------------------------------------
|
||||
|
||||
/** Set (or clear, with `''`) one cell and recalculate the affected cells. */
|
||||
setCell(tableId: string, col: number, row: number, raw: string): void {
|
||||
const table = this.tableById(tableId);
|
||||
if (!table) return;
|
||||
const lk = localKey(col, row);
|
||||
if (raw === '') table.inputs.delete(lk);
|
||||
else table.inputs.set(lk, raw);
|
||||
|
||||
const key = keyOf(tableId, col, row);
|
||||
this.parseCell(key, tableId, raw);
|
||||
this.recalc([key]);
|
||||
}
|
||||
|
||||
/** Reparse every formula, rebuild the graph, recompute everything. */
|
||||
rebuild(): void {
|
||||
this.graph.reset();
|
||||
this.asts.clear();
|
||||
const all: string[] = [];
|
||||
for (const table of this.tables) {
|
||||
for (const [lk, raw] of table.inputs) {
|
||||
const [c, r] = lk.split(',').map(Number);
|
||||
const key = keyOf(table.id, c, r);
|
||||
all.push(key);
|
||||
this.parseCell(key, table.id, raw);
|
||||
}
|
||||
}
|
||||
this.recalcAll(all);
|
||||
}
|
||||
|
||||
// ---- internals ------------------------------------------------------
|
||||
|
||||
/** Parse one cell's raw input and (re)wire its graph edges. */
|
||||
private parseCell(key: string, tableId: string, raw: string): void {
|
||||
if (!raw.startsWith('=')) {
|
||||
this.asts.delete(key);
|
||||
this.graph.clear(key);
|
||||
return;
|
||||
}
|
||||
let ast: Node;
|
||||
try {
|
||||
ast = parseFormula(raw);
|
||||
ast = expandUserFunctions(ast, this.functionIndex);
|
||||
} catch (e) {
|
||||
// a bad formula is a cell-level error, never a crash
|
||||
this.asts.set(key, new FormulaError('#ERR', (e as Error).message));
|
||||
this.graph.setPrecedents(key, []);
|
||||
return;
|
||||
}
|
||||
this.asts.set(key, ast);
|
||||
this.graph.setPrecedents(key, this.precedentsOf(ast, tableId));
|
||||
}
|
||||
|
||||
/** Resolve every reference in `ast` to concrete cell keys (home = tableId). */
|
||||
private precedentsOf(ast: Node, tableId: string): string[] {
|
||||
// resolveTableName()'s "prefer the home sheet" ambiguity fallback reads
|
||||
// this.homeTableId -- set it here too (not just in evaluateCell) so graph
|
||||
// edges are wired using the *same* resolution an evaluation would use.
|
||||
this.homeTableId = tableId;
|
||||
const out: string[] = [];
|
||||
for (const ref of collectRefs(ast)) {
|
||||
// a named range isn't itself a qualifier/coords pair -- resolve it to
|
||||
// one first (same shape as a 'cell'/'range' ref), then fall through to
|
||||
// the identical edge-wiring below.
|
||||
const resolved = ref.kind === 'namedref' ? this.resolveNamedRange(ref.name) : ref;
|
||||
if (!resolved) continue; // unknown name -> no edge; evaluator emits #NAME?
|
||||
const target =
|
||||
resolved.qualifier == null
|
||||
? tableId
|
||||
: this.resolveTableName(resolved.qualifier, resolved.sheetQualifier ?? null);
|
||||
if (!target) continue; // unknown table -> no edge; evaluator emits #REF
|
||||
if ('col' in resolved) {
|
||||
out.push(keyOf(target, resolved.col, resolved.row));
|
||||
} else {
|
||||
for (const c of rangeCells(resolved, this.tableSize(target))) {
|
||||
out.push(keyOf(target, c.col, c.row));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Incremental recalculation starting from `dirty`. */
|
||||
private recalc(dirty: string[]): void {
|
||||
this.evaluateSet(this.graph.affected(dirty));
|
||||
}
|
||||
|
||||
/** Full recalculation of the given (complete) cell set. */
|
||||
private recalcAll(all: string[]): void {
|
||||
this.values.clear();
|
||||
this.evaluateSet(new Set(all));
|
||||
}
|
||||
|
||||
private evaluateSet(affected: Set<string>): void {
|
||||
const { order, circular } = this.graph.topoOrder(affected);
|
||||
for (const key of circular) this.values.set(key, new FormulaError('#CIRC'));
|
||||
for (const key of order) this.values.set(key, this.evaluateCell(key));
|
||||
}
|
||||
|
||||
private evaluateCell(key: string): CellValue {
|
||||
const ast = this.asts.get(key);
|
||||
if (ast === undefined) {
|
||||
const { tableId, col, row } = parseKey(key);
|
||||
return literalValue(this.getInput(tableId, col, row));
|
||||
}
|
||||
if (isError(ast)) return ast;
|
||||
this.homeTableId = parseKey(key).tableId;
|
||||
try {
|
||||
return evaluate(ast, this);
|
||||
} catch {
|
||||
return new FormulaError('#ERR', 'evaluation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Recompute order for a hand-picked dirty set -- exposed for tests. */
|
||||
debugOrder(dirty: string[]): { order: string[]; circular: string[] } {
|
||||
const { order, circular } = this.graph.topoOrder(this.graph.affected(dirty));
|
||||
return { order, circular: [...circular] };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from '../App.vue';
|
||||
import { useLibrary } from '../store/library';
|
||||
import '../styles/main.css';
|
||||
|
||||
// open the document (from IndexedDB) before the first render, so the app
|
||||
// never flashes an empty document first
|
||||
void useLibrary()
|
||||
.init()
|
||||
.finally(() => createApp(App).mount('#app'));
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
import { DocumentLibrary, newDocId, type DocMeta } from '../library';
|
||||
|
||||
const meta = (id: string, title: string, updatedAt: number): DocMeta => ({
|
||||
id,
|
||||
title,
|
||||
createdAt: 1,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
/** a fresh, empty in-memory IndexedDB per test */
|
||||
const openLib = () => DocumentLibrary.open(new IDBFactory());
|
||||
|
||||
describe('DocumentLibrary', () => {
|
||||
it('stores meta and content separately and reads them back', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('a', 'Budget', 10), '{"x":1}');
|
||||
expect(await lib.getMeta('a')).toEqual(meta('a', 'Budget', 10));
|
||||
expect(await lib.getContent('a')).toBe('{"x":1}');
|
||||
expect(await lib.getContent('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists documents most recently changed first', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('old', 'Alt', 10), '{}');
|
||||
await lib.put(meta('new', 'Neu', 30), '{}');
|
||||
await lib.put(meta('mid', 'Mitte', 20), '{}');
|
||||
expect((await lib.list()).map((d) => d.id)).toEqual(['new', 'mid', 'old']);
|
||||
});
|
||||
|
||||
it('patchMeta renames without touching content; unknown ids are ignored', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('a', 'Alt', 10), '{"keep":true}');
|
||||
await lib.patchMeta('a', { title: 'Neu' });
|
||||
await lib.patchMeta('nope', { title: 'X' });
|
||||
expect((await lib.getMeta('a'))?.title).toBe('Neu');
|
||||
expect(await lib.getContent('a')).toBe('{"keep":true}');
|
||||
expect(await lib.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('remove deletes meta and content', async () => {
|
||||
const lib = await openLib();
|
||||
await lib.put(meta('a', 'A', 10), '{}');
|
||||
await lib.remove('a');
|
||||
expect(await lib.getMeta('a')).toBeUndefined();
|
||||
expect(await lib.getContent('a')).toBeUndefined();
|
||||
expect(await lib.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps data across re-opening the same database', async () => {
|
||||
const factory = new IDBFactory();
|
||||
await (await DocumentLibrary.open(factory)).put(meta('a', 'A', 10), '{"v":2}');
|
||||
expect(await (await DocumentLibrary.open(factory)).getContent('a')).toBe('{"v":2}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('newDocId', () => {
|
||||
it('produces distinct v4 UUIDs', () => {
|
||||
const a = newDocId();
|
||||
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(newDocId()).not.toBe(a);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
|
||||
import { useDocument } from '../../store/document';
|
||||
import { serializeDocument } from '../json';
|
||||
import { importJsonReplace, readNumFile } from '../operations';
|
||||
import { NUM_MIMETYPE, NumFileError, packNum, unpackNum } from '../num';
|
||||
|
||||
describe('.num files', () => {
|
||||
it('round-trip the document JSON exactly', async () => {
|
||||
const s = useDocument();
|
||||
s.resetToSeed();
|
||||
const json = serializeDocument(s.snapshot());
|
||||
const bytes = packNum(json);
|
||||
expect(unpackNum(bytes)).toBe(json);
|
||||
// and import back into the store like a .json file
|
||||
s.newDocument();
|
||||
importJsonReplace(s, await readNumFile(bytes.slice().buffer));
|
||||
expect(serializeDocument(s.snapshot())).toBe(json);
|
||||
});
|
||||
|
||||
it('are smaller than the JSON they carry', () => {
|
||||
const s = useDocument();
|
||||
s.resetToSeed();
|
||||
const json = serializeDocument(s.snapshot());
|
||||
expect(packNum(json).length).toBeLessThan(json.length);
|
||||
});
|
||||
|
||||
it('start with an uncompressed mimetype entry, plus meta.json with an ISO timestamp', () => {
|
||||
const bytes = packNum('{}', new Date('2026-09-28T10:00:00Z'));
|
||||
// local file header (30 bytes) + "mimetype" (8) -> the stored content
|
||||
expect(strFromU8(bytes.subarray(30, 38))).toBe('mimetype');
|
||||
expect(strFromU8(bytes.subarray(38, 38 + NUM_MIMETYPE.length))).toBe(NUM_MIMETYPE);
|
||||
const meta = JSON.parse(strFromU8(unzipSync(bytes)['meta.json']));
|
||||
expect(meta).toEqual({ format: 'numolus', formatVersion: 1, created: '2026-09-28T10:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('reject what is not a numolus file', () => {
|
||||
expect(() => unpackNum(strToU8('{"version":2}'))).toThrow(NumFileError);
|
||||
expect(() => unpackNum(zipSync({ mimetype: strToU8('application/zip') }))).toThrow(/Keine numolus-Datei/);
|
||||
expect(() => unpackNum(zipSync({ mimetype: strToU8(NUM_MIMETYPE) }))).toThrow(/kein document.json/);
|
||||
const newer = zipSync({
|
||||
mimetype: strToU8(NUM_MIMETYPE),
|
||||
'meta.json': strToU8('{"formatVersion": 99}'),
|
||||
'document.json': strToU8('{}'),
|
||||
});
|
||||
expect(() => unpackNum(newer)).toThrow(/neueren numolus-Version/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Import/export acceptance checks at the pure (de)serialisation layer -- the
|
||||
* `<a download>` glue is trivial and browser-only, so it is not exercised here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useDocument, type DocumentStore } from '../../store/document';
|
||||
import { isError } from '../../engine';
|
||||
import { serializeDocument, parseDocument, JsonImportError } from '../json';
|
||||
import { tableToCsv, parseCsv, matrixToCells } from '../csv';
|
||||
import { importJsonReplace } from '../operations';
|
||||
|
||||
function fresh(): DocumentStore {
|
||||
const s = useDocument();
|
||||
s.resetToSeed();
|
||||
return s;
|
||||
}
|
||||
function tableId(s: DocumentStore, name: string): string {
|
||||
for (const sh of s.doc.sheets) {
|
||||
const o = sh.objects.find((x) => x.kind === 'table' && x.name === name);
|
||||
if (o) return o.id;
|
||||
}
|
||||
throw new Error(name);
|
||||
}
|
||||
const coords = (a1: string) => {
|
||||
const m = /^([A-Z]+)([0-9]+)$/.exec(a1)!;
|
||||
return {
|
||||
col: m[1].split('').reduce((n, c) => n * 26 + (c.charCodeAt(0) - 64), 0) - 1,
|
||||
row: Number(m[2]) - 1,
|
||||
};
|
||||
};
|
||||
|
||||
describe('JSON native format', () => {
|
||||
it('round-trips a document losslessly (export → reset → import → identical)', () => {
|
||||
const s = fresh();
|
||||
const before = s.snapshot();
|
||||
const json = serializeDocument(before);
|
||||
|
||||
s.addSheet('scratch');
|
||||
s.setCellInput(tableId(s, 'Umsatz'), 1, 1, '999999');
|
||||
|
||||
s.replaceDocument(parseDocument(json));
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it('rejects corrupt / empty input and leaves the document untouched', () => {
|
||||
const s = fresh();
|
||||
const before = s.snapshot();
|
||||
|
||||
expect(() => importJsonReplace(s, '{ not json')).toThrow(JsonImportError);
|
||||
expect(() => importJsonReplace(s, JSON.stringify({ version: 2, sheets: [] }))).toThrow(
|
||||
JsonImportError,
|
||||
);
|
||||
expect(() => importJsonReplace(s, JSON.stringify({ version: 7 }))).toThrow(JsonImportError);
|
||||
|
||||
expect(s.snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it('an unknown table name in an imported formula yields #REF, no crash', () => {
|
||||
const s = fresh();
|
||||
const doc = {
|
||||
version: 2,
|
||||
sheets: [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'S1',
|
||||
objects: [
|
||||
{
|
||||
kind: 'table',
|
||||
id: 't1',
|
||||
name: 'T1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
rows: 4,
|
||||
cols: 4,
|
||||
colWidths: {},
|
||||
cells: { '0,0': '=Nirgends!A1', '1,0': '=A1+1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activeSheetId: 's1',
|
||||
activeObjectId: null,
|
||||
selection: null,
|
||||
};
|
||||
importJsonReplace(s, JSON.stringify(doc));
|
||||
const a1 = s.cellValue('t1', 0, 0);
|
||||
const b1 = s.cellValue('t1', 1, 0);
|
||||
expect(isError(a1) && a1.code).toBe('#REF');
|
||||
expect(isError(b1) && b1.code).toBe('#REF'); // error propagates
|
||||
});
|
||||
|
||||
it('normalises a v2 doc saved by an older build (missing table.view)', () => {
|
||||
const stale = {
|
||||
version: 2,
|
||||
sheets: [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'S1',
|
||||
objects: [
|
||||
// no `view` key -- as written before the frozen-panes feature
|
||||
{ kind: 'table', id: 't1', name: 'T1', x: 10, y: 10, rows: 8, cols: 5, colWidths: {}, cells: {} },
|
||||
],
|
||||
},
|
||||
],
|
||||
activeSheetId: 's1',
|
||||
activeObjectId: null,
|
||||
selection: null,
|
||||
};
|
||||
const doc = parseDocument(JSON.stringify(stale));
|
||||
const t = doc.sheets[0].objects[0];
|
||||
expect(t.kind).toBe('table');
|
||||
if (t.kind === 'table') {
|
||||
expect(t.view).toEqual({ mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
it('migrates a v1 document on import', () => {
|
||||
const s = fresh();
|
||||
const v1 = JSON.stringify({
|
||||
version: 1,
|
||||
sheets: [
|
||||
{ id: 'a', name: 'Zahlen', rows: 5, cols: 3, colWidths: {}, cells: { '0,0': '10', '0,1': '=A1*3' } },
|
||||
],
|
||||
activeSheetId: 'a',
|
||||
selection: null,
|
||||
});
|
||||
importJsonReplace(s, v1);
|
||||
expect(s.doc.version).toBe(2);
|
||||
const id = tableId(s, 'Zahlen');
|
||||
expect(s.cellValue(id, 0, 1)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSV', () => {
|
||||
it('exports computed values of "Übersicht" and re-imports to the same numbers', () => {
|
||||
const s = fresh();
|
||||
const src = tableId(s, 'Übersicht');
|
||||
const csv = tableToCsv(s, src, { delimiter: ',', bom: false, crlf: true });
|
||||
|
||||
expect(csv.includes('\r\n')).toBe(true);
|
||||
// header row is text, body carries the computed numbers
|
||||
expect(csv.split('\r\n')[0]).toBe('Region,Umsatz Jahr,Kosten Jahr,Deckungsbeitrag,Marge %');
|
||||
|
||||
const { cells, rows, cols } = matrixToCells(parseCsv(csv, ','), true);
|
||||
const copyId = s.addTableFromCells(s.doc.sheets[1].id, 'Kopie', cells, rows, cols);
|
||||
|
||||
for (const a1 of ['B2', 'C2', 'D2', 'B8', 'C8', 'D8']) {
|
||||
const { col, row } = coords(a1);
|
||||
expect(s.cellValue(copyId, col, row)).toBe(s.cellValue(src, col, row));
|
||||
}
|
||||
});
|
||||
|
||||
it('RFC-4180 quoting: a field with a comma is wrapped and survives the round-trip', () => {
|
||||
const s = fresh();
|
||||
const id = s.addTable(s.doc.sheets[1].id);
|
||||
s.setCellInput(id, 0, 0, 'Müller, Hans');
|
||||
s.setCellInput(id, 1, 0, 'x"y');
|
||||
const csv = tableToCsv(s, id, { delimiter: ',', bom: false, crlf: true });
|
||||
expect(csv).toContain('"Müller, Hans"');
|
||||
expect(csv).toContain('"x""y"');
|
||||
const [line] = parseCsv(csv, ',');
|
||||
expect(line).toEqual(['Müller, Hans', 'x"y']);
|
||||
});
|
||||
|
||||
it('a CSV cell "=1+2" becomes a formula that evaluates to 3', () => {
|
||||
const s = fresh();
|
||||
const { cells, rows, cols } = matrixToCells(parseCsv('=1+2\n', ','), true);
|
||||
const id = s.addTableFromCells(s.doc.sheets[1].id, 'Rechnen', cells, rows, cols);
|
||||
expect(s.cellValue(id, 0, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it('locale number detection (de vs en)', () => {
|
||||
const { cells } = matrixToCells(parseCsv('1.234,56;7,89;1,234.50\n', ';'), true);
|
||||
expect(cells['0,0']).toBe('1234.56');
|
||||
expect(cells['1,0']).toBe('7.89');
|
||||
expect(cells['2,0']).toBe('1234.50');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The numolus server API (Rails, /api). JSON in and out, the session cookie
|
||||
* for the signed-in user, the CSRF token from the page's meta tag, and two
|
||||
* per-request extras: X-Client-Id (this browser tab, the edit-lock holder)
|
||||
* and X-Share-Token (a share link's token, for access via link).
|
||||
*/
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: Record<string, unknown>,
|
||||
) {
|
||||
super(ApiError.describe(status, body));
|
||||
}
|
||||
static describe(status: number, body: Record<string, unknown>): string {
|
||||
if (status === 0) return 'Keine Verbindung zum Server.';
|
||||
if (status === 401) return 'Nicht angemeldet.';
|
||||
if (status === 404) return 'Nicht gefunden oder kein Zugriff.';
|
||||
if (status === 429) return 'Zu viele Versuche – bitte später noch einmal.';
|
||||
const msgs = body.messages;
|
||||
if (Array.isArray(msgs) && msgs.length) return msgs.join(', ');
|
||||
return `Serverfehler (${status}).`;
|
||||
}
|
||||
}
|
||||
|
||||
export type Role = 'owner' | 'editor' | 'viewer';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ServerDocMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
version: number;
|
||||
role: Role;
|
||||
owner_name: string;
|
||||
updated_at: string;
|
||||
updated_by_name: string | null;
|
||||
}
|
||||
|
||||
export interface ServerDoc extends ServerDocMeta {
|
||||
content?: string;
|
||||
lock?: { locked_by_other: boolean; holder_name: string | null };
|
||||
}
|
||||
|
||||
export interface ShareInfo {
|
||||
links: { id: number; role: 'viewer' | 'editor'; token: string; created_at: string }[];
|
||||
members: { user_id: number; name: string; email: string; role: 'viewer' | 'editor'; source: 'invite' | 'link' }[];
|
||||
invitations: { email: string; role: 'viewer' | 'editor'; expires_at: string }[];
|
||||
}
|
||||
|
||||
/** One id per browser tab, kept across reloads of that tab. */
|
||||
export const clientId: string = (() => {
|
||||
const key = 'numolus:client-id';
|
||||
try {
|
||||
const existing = sessionStorage.getItem(key);
|
||||
if (existing) return existing;
|
||||
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
sessionStorage.setItem(key, id);
|
||||
return id;
|
||||
} catch {
|
||||
return Math.random().toString(36).slice(2);
|
||||
}
|
||||
})();
|
||||
|
||||
function csrfToken(): string {
|
||||
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
|
||||
}
|
||||
|
||||
interface RequestOpts {
|
||||
body?: unknown;
|
||||
shareToken?: string | null;
|
||||
/** survives the page being closed (small bodies only, < 64 KB) */
|
||||
keepalive?: boolean;
|
||||
}
|
||||
|
||||
export async function api<T = Record<string, unknown>>(method: string, path: string, opts: RequestOpts = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-Token': csrfToken(),
|
||||
'X-Client-Id': clientId,
|
||||
};
|
||||
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (opts.shareToken) headers['X-Share-Token'] = opts.shareToken;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`/api${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
credentials: 'same-origin',
|
||||
keepalive: opts.keepalive,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, {});
|
||||
}
|
||||
if (res.status === 204) return {} as T;
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (!res.ok) throw new ApiError(res.status, body);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
/** Data the server put into the page (see app/views/spa/show.html.erb). */
|
||||
export function bootData(): { user: User | null } {
|
||||
try {
|
||||
const el = document.getElementById('numolus-boot');
|
||||
return el ? JSON.parse(el.textContent || '{}') : { user: null };
|
||||
} catch {
|
||||
return { user: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* CSV import/export -- hand-written, no library.
|
||||
*
|
||||
* Export follows RFC 4180: fields containing the delimiter, a double quote, CR
|
||||
* or LF are wrapped in double quotes and inner quotes are doubled. Line ending
|
||||
* is CRLF, optional UTF-8 BOM for Excel. Delimiter is "," or ";".
|
||||
*
|
||||
* Import: a small state-machine parser that understands quoted fields with
|
||||
* embedded delimiters / newlines, plus delimiter sniffing and de/en locale
|
||||
* number normalisation. A leading "=" makes the cell a formula.
|
||||
*/
|
||||
|
||||
import type { DocumentStore } from '../store/document';
|
||||
import { rawString } from '../store/format';
|
||||
|
||||
export type Delimiter = ',' | ';' | '\t';
|
||||
|
||||
export interface CsvExportOptions {
|
||||
delimiter: Delimiter;
|
||||
bom: boolean;
|
||||
crlf: boolean;
|
||||
}
|
||||
|
||||
// ---- export --------------------------------------------------------------
|
||||
|
||||
function needsQuoting(field: string, delimiter: string): boolean {
|
||||
return (
|
||||
field.includes(delimiter) ||
|
||||
field.includes('"') ||
|
||||
field.includes('\n') ||
|
||||
field.includes('\r')
|
||||
);
|
||||
}
|
||||
|
||||
function quoteField(field: string, delimiter: string): string {
|
||||
if (!needsQuoting(field, delimiter)) return field;
|
||||
return `"${field.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
/** Bounding box of the used area of a sheet. */
|
||||
function usedBounds(cells: Record<string, string>): { rows: number; cols: number } {
|
||||
let rows = 0;
|
||||
let cols = 0;
|
||||
for (const key of Object.keys(cells)) {
|
||||
const [c, r] = key.split(',').map(Number);
|
||||
if (c + 1 > cols) cols = c + 1;
|
||||
if (r + 1 > rows) rows = r + 1;
|
||||
}
|
||||
return { rows, cols };
|
||||
}
|
||||
|
||||
export function tableToCsv(
|
||||
store: DocumentStore,
|
||||
tableId: string,
|
||||
opts: CsvExportOptions,
|
||||
): string {
|
||||
const table = store.tableById(tableId);
|
||||
if (!table) return '';
|
||||
const bounds = usedBounds(table.cells);
|
||||
const rows = Math.max(bounds.rows, 1);
|
||||
const cols = Math.max(bounds.cols, 1);
|
||||
const eol = opts.crlf ? '\r\n' : '\n';
|
||||
|
||||
const lines: string[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const fields: string[] = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const value = rawString(store.cellValue(tableId, c, r));
|
||||
fields.push(quoteField(value, opts.delimiter));
|
||||
}
|
||||
lines.push(fields.join(opts.delimiter));
|
||||
}
|
||||
return (opts.bom ? '' : '') + lines.join(eol) + eol;
|
||||
}
|
||||
|
||||
// ---- import --------------------------------------------------------------
|
||||
|
||||
export function sniffDelimiter(text: string): Delimiter {
|
||||
const firstLine = text.slice(0, text.search(/\r?\n/) >>> 0 || text.length);
|
||||
const counts: Record<Delimiter, number> = { ',': 0, ';': 0, '\t': 0 };
|
||||
let inQuotes = false;
|
||||
for (const ch of firstLine) {
|
||||
if (ch === '"') inQuotes = !inQuotes;
|
||||
else if (!inQuotes && (ch === ',' || ch === ';' || ch === '\t')) counts[ch]++;
|
||||
}
|
||||
const best = (Object.keys(counts) as Delimiter[]).sort(
|
||||
(a, b) => counts[b] - counts[a],
|
||||
)[0];
|
||||
return counts[best] > 0 ? best : ',';
|
||||
}
|
||||
|
||||
/** RFC-4180-ish parser. Returns a matrix of raw string cells. */
|
||||
export function parseCsv(text: string, delimiter: Delimiter): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
// strip BOM
|
||||
if (text.charCodeAt(0) === 0xfeff) i = 1;
|
||||
|
||||
const endField = () => {
|
||||
row.push(field);
|
||||
field = '';
|
||||
};
|
||||
const endRow = () => {
|
||||
endField();
|
||||
rows.push(row);
|
||||
row = [];
|
||||
};
|
||||
|
||||
while (i < n) {
|
||||
const ch = text[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
inQuotes = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
field += ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === delimiter) {
|
||||
endField();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') {
|
||||
if (text[i + 1] === '\n') i++;
|
||||
endRow();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n') {
|
||||
endRow();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
field += ch;
|
||||
i++;
|
||||
}
|
||||
// trailing field / row (unless the file ended exactly on a newline)
|
||||
if (field !== '' || row.length > 0) endRow();
|
||||
return rows;
|
||||
}
|
||||
|
||||
const DE_NUMBER = /^-?\d{1,3}(\.\d{3})+(,\d+)?$|^-?\d+,\d+$/;
|
||||
const EN_NUMBER = /^-?\d{1,3}(,\d{3})+(\.\d+)?$|^-?\d+\.\d+$/;
|
||||
|
||||
/** Normalise a locale-formatted number to a plain JS number literal string. */
|
||||
export function normalizeNumber(value: string): string {
|
||||
const v = value.trim();
|
||||
if (v === '' || v.startsWith('=')) return value;
|
||||
if (/^-?\d+$/.test(v)) return v;
|
||||
if (DE_NUMBER.test(v)) return v.replace(/\./g, '').replace(',', '.');
|
||||
if (EN_NUMBER.test(v)) return v.replace(/,/g, '');
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface CsvToCellsResult {
|
||||
cells: Record<string, string>;
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export function matrixToCells(
|
||||
matrix: string[][],
|
||||
detectNumbers = true,
|
||||
): CsvToCellsResult {
|
||||
const cells: Record<string, string> = {};
|
||||
let cols = 0;
|
||||
matrix.forEach((line, r) => {
|
||||
if (line.length > cols) cols = line.length;
|
||||
line.forEach((cell, c) => {
|
||||
let raw = cell;
|
||||
if (detectNumbers) raw = normalizeNumber(raw);
|
||||
if (raw !== '') cells[`${c},${r}`] = raw;
|
||||
});
|
||||
});
|
||||
return { cells, rows: matrix.length, cols: Math.max(cols, 1) };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Trigger a client-side file download from a Blob. */
|
||||
export function downloadBlob(filename: string, blob: Blob): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
// revoke on the next tick so the download has started
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export function downloadText(
|
||||
filename: string,
|
||||
text: string,
|
||||
mime = 'text/plain;charset=utf-8',
|
||||
): void {
|
||||
downloadBlob(filename, new Blob([text], { type: mime }));
|
||||
}
|
||||
|
||||
export function downloadDataUrl(filename: string, dataUrl: string): void {
|
||||
const a = document.createElement('a');
|
||||
a.href = dataUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Native JSON format -- full-fidelity, lossless round-trip.
|
||||
* Contains every sheet, every table/chart object with position & size, raw
|
||||
* formulas, column widths, tab order, the active object and the cell selection.
|
||||
* No library: `JSON.stringify` / `JSON.parse` plus schema validation.
|
||||
*
|
||||
* Version 1 documents (one grid per sheet) are migrated to version 2
|
||||
* (canvas + objects) on import.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ButtonObject,
|
||||
CellCoord,
|
||||
CellFormat,
|
||||
ChartObject,
|
||||
CustomFunction,
|
||||
DocumentState,
|
||||
HAlign,
|
||||
Macro,
|
||||
NamedRange,
|
||||
Sheet,
|
||||
SheetObject,
|
||||
TableObject,
|
||||
TableView,
|
||||
TextObject,
|
||||
} from '../store/types';
|
||||
import { defaultTableView, MIN_GRID_SIZE, MAX_GRID_SIZE } from '../store/types';
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
isCellNumFmt,
|
||||
isNumberLocale,
|
||||
type NumberLocale,
|
||||
} from '../store/format';
|
||||
|
||||
/** Keep a valid locale override, drop anything else (undefined = inherit). */
|
||||
function localeOrUndef(x: unknown): NumberLocale | undefined {
|
||||
return isNumberLocale(x) ? x : undefined;
|
||||
}
|
||||
|
||||
/** Defaults to `[]` -- absent in every document saved before this feature
|
||||
* existed (and in v1 documents entirely) -- and drops any malformed entry
|
||||
* rather than rejecting the whole import over one bad function. */
|
||||
function normaliseCustomFunctions(x: unknown): CustomFunction[] {
|
||||
if (!Array.isArray(x)) return [];
|
||||
const out: CustomFunction[] = [];
|
||||
for (const f of x) {
|
||||
if (
|
||||
!f ||
|
||||
typeof f !== 'object' ||
|
||||
typeof (f as { id?: unknown }).id !== 'string' ||
|
||||
typeof (f as { name?: unknown }).name !== 'string' ||
|
||||
typeof (f as { body?: unknown }).body !== 'string' ||
|
||||
!Array.isArray((f as { params?: unknown }).params) ||
|
||||
!(f as { params: unknown[] }).params.every((p) => typeof p === 'string')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const rec = f as CustomFunction;
|
||||
const kind = rec.kind === 'block' ? 'block' : 'expression'; // default for anything missing/invalid
|
||||
out.push({ id: rec.id, name: rec.name, params: [...rec.params], body: rec.body, kind });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isCellCoord(x: unknown): x is CellCoord {
|
||||
return (
|
||||
!!x &&
|
||||
typeof x === 'object' &&
|
||||
typeof (x as CellCoord).col === 'number' &&
|
||||
typeof (x as CellCoord).row === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/** Defaults to `[]` -- absent in every document saved before this feature
|
||||
* existed. Drops a malformed entry, and any entry whose `tableId` doesn't
|
||||
* match a real table *in this document* (a dangling reference -- e.g. a
|
||||
* hand-edited file, or a table that no longer exists) -- rather than
|
||||
* leaving it to resolve to `#NAME?` forever; the live "delete a table"
|
||||
* path (`document.ts`) does leave such entries in place, for consistency
|
||||
* with how a deleted table's ordinary formula references are never
|
||||
* auto-pruned either -- this is stricter only because *importing* JSON is
|
||||
* already a validating boundary for everything else in the document. */
|
||||
function normaliseNamedRanges(x: unknown, tableIds: ReadonlySet<string>): NamedRange[] {
|
||||
if (!Array.isArray(x)) return [];
|
||||
const out: NamedRange[] = [];
|
||||
for (const r of x) {
|
||||
if (
|
||||
!r ||
|
||||
typeof r !== 'object' ||
|
||||
typeof (r as { id?: unknown }).id !== 'string' ||
|
||||
typeof (r as { name?: unknown }).name !== 'string' ||
|
||||
typeof (r as { tableId?: unknown }).tableId !== 'string' ||
|
||||
!isCellCoord((r as { start?: unknown }).start) ||
|
||||
!isCellCoord((r as { end?: unknown }).end)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const rec = r as NamedRange;
|
||||
if (!tableIds.has(rec.tableId)) continue;
|
||||
out.push({ id: rec.id, name: rec.name, tableId: rec.tableId, start: { ...rec.start }, end: { ...rec.end } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Defaults to `[]` -- absent in every document saved before this feature
|
||||
* existed. Unlike a named range, a macro carries no `tableId`/other
|
||||
* document-internal reference to validate against -- its body is a plain
|
||||
* string, parsed and validated lazily (store/document.ts's `validateMacro`)
|
||||
* only when actually edited/run, same lenient convention as a custom
|
||||
* function's `body` (an import doesn't re-run every save-time check, only
|
||||
* shape validation). */
|
||||
function normaliseMacros(x: unknown): Macro[] {
|
||||
if (!Array.isArray(x)) return [];
|
||||
const out: Macro[] = [];
|
||||
for (const m of x) {
|
||||
if (
|
||||
!m ||
|
||||
typeof m !== 'object' ||
|
||||
typeof (m as { id?: unknown }).id !== 'string' ||
|
||||
typeof (m as { name?: unknown }).name !== 'string' ||
|
||||
typeof (m as { body?: unknown }).body !== 'string'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const rec = m as Macro;
|
||||
const approvedHosts =
|
||||
Array.isArray(rec.approvedHosts) && rec.approvedHosts.every((h) => typeof h === 'string')
|
||||
? rec.approvedHosts
|
||||
: undefined;
|
||||
out.push({ id: rec.id, name: rec.name, body: rec.body, ...(approvedHosts ? { approvedHosts } : {}) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
||||
function colorOrUndef(x: unknown): string | undefined {
|
||||
return typeof x === 'string' && HEX_COLOR.test(x) ? x : undefined;
|
||||
}
|
||||
function boolOrUndef(x: unknown): boolean | undefined {
|
||||
return typeof x === 'boolean' ? x : undefined;
|
||||
}
|
||||
function zoomOrUndef(x: unknown): number | undefined {
|
||||
return typeof x === 'number' && Number.isFinite(x)
|
||||
? Math.min(2, Math.max(0.4, Math.round(x * 20) / 20))
|
||||
: undefined;
|
||||
}
|
||||
function gridSizeOrUndef(x: unknown): number | undefined {
|
||||
return typeof x === 'number' && Number.isFinite(x)
|
||||
? Math.round(Math.min(MAX_GRID_SIZE, Math.max(MIN_GRID_SIZE, x)))
|
||||
: undefined;
|
||||
}
|
||||
function clampNum(x: unknown, lo: number, hi: number, fallback: number): number {
|
||||
return typeof x === 'number' && Number.isFinite(x)
|
||||
? Math.min(hi, Math.max(lo, Math.round(x)))
|
||||
: fallback;
|
||||
}
|
||||
const H_ALIGNS = new Set<HAlign>(['left', 'center', 'right']);
|
||||
function alignOrUndef(x: unknown): HAlign | undefined {
|
||||
return H_ALIGNS.has(x as HAlign) ? (x as HAlign) : undefined;
|
||||
}
|
||||
|
||||
/** Validate + trim one CellFormat; returns undefined if nothing valid remains. */
|
||||
function normaliseCellFormat(x: unknown): CellFormat | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const f = x as CellFormat;
|
||||
const out: CellFormat = {};
|
||||
if (alignOrUndef(f.align)) out.align = f.align;
|
||||
if (f.valign === 'top' || f.valign === 'middle' || f.valign === 'bottom') out.valign = f.valign;
|
||||
if (isCellNumFmt(f.numFmt) && f.numFmt !== 'auto') out.numFmt = f.numFmt;
|
||||
if (colorOrUndef(f.color)) out.color = f.color;
|
||||
if (colorOrUndef(f.bg)) out.bg = f.bg;
|
||||
if (f.bold === true) out.bold = true;
|
||||
if (f.italic === true) out.italic = true;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
function normaliseFormats(x: unknown): Record<string, CellFormat> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<string, CellFormat> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
if (!/^\d+,\d+$/.test(k)) continue;
|
||||
const f = normaliseCellFormat(v);
|
||||
if (f) out[k] = f;
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
function normaliseRowHeights(x: unknown): Record<number, number> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
if (!/^\d+$/.test(k)) continue;
|
||||
if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[Number(k)] = Math.min(240, Math.max(14, Math.round(v)));
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
function coord(x: unknown): { col: number; row: number } | null {
|
||||
const c = x as { col?: unknown; row?: unknown };
|
||||
if (!c || typeof c !== 'object') return null;
|
||||
if (typeof c.col !== 'number' || typeof c.row !== 'number') return null;
|
||||
return { col: Math.max(0, Math.floor(c.col)), row: Math.max(0, Math.floor(c.row)) };
|
||||
}
|
||||
function normaliseSelection(x: unknown): DocumentState['selection'] {
|
||||
const s = x as { tableId?: unknown; anchor?: unknown; focus?: unknown; ranges?: unknown };
|
||||
const anchor = coord(s.anchor);
|
||||
const focus = coord(s.focus);
|
||||
if (typeof s.tableId !== 'string' || !anchor || !focus) return null;
|
||||
const ranges = Array.isArray(s.ranges)
|
||||
? s.ranges
|
||||
.map((r) => {
|
||||
const a = coord((r as { anchor?: unknown }).anchor);
|
||||
const f = coord((r as { focus?: unknown }).focus);
|
||||
return a && f ? { anchor: a, focus: f } : null;
|
||||
})
|
||||
.filter((r): r is { anchor: { col: number; row: number }; focus: { col: number; row: number } } => !!r)
|
||||
: [];
|
||||
return { tableId: s.tableId, anchor, focus, ranges: ranges.length ? ranges : undefined };
|
||||
}
|
||||
function numMapOrUndef(x: unknown): Record<number, number> | undefined {
|
||||
if (!x || typeof x !== 'object') return undefined;
|
||||
const out: Record<number, number> = {};
|
||||
for (const [k, v] of Object.entries(x as Record<string, unknown>)) {
|
||||
if (/^\d+$/.test(k) && typeof v === 'number' && Number.isFinite(v)) {
|
||||
out[Number(k)] = Math.max(20, Math.round(v));
|
||||
}
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
|
||||
export class JsonImportError extends Error {}
|
||||
|
||||
// ---- v2 validation ----------------------------------------------------
|
||||
|
||||
function isRecordOfString(x: unknown): x is Record<string, string> {
|
||||
return (
|
||||
!!x &&
|
||||
typeof x === 'object' &&
|
||||
Object.values(x as Record<string, unknown>).every((v) => typeof v === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function isTableObject(x: unknown): x is TableObject {
|
||||
const o = x as TableObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'table' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.rows) &&
|
||||
Number.isFinite(o.cols) &&
|
||||
!!o.colWidths &&
|
||||
typeof o.colWidths === 'object' &&
|
||||
isRecordOfString(o.cells) &&
|
||||
Object.keys(o.cells).every((k) => /^\d+,\d+$/.test(k))
|
||||
);
|
||||
}
|
||||
|
||||
function isChartObject(x: unknown): x is ChartObject {
|
||||
const o = x as ChartObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'chart' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.width) &&
|
||||
Number.isFinite(o.height) &&
|
||||
(o.view === 'bar' || o.view === 'line')
|
||||
);
|
||||
}
|
||||
|
||||
function isTextObject(x: unknown): x is TextObject {
|
||||
const o = x as TextObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'text' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.width) &&
|
||||
Number.isFinite(o.height) &&
|
||||
typeof o.text === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function isButtonObject(x: unknown): x is ButtonObject {
|
||||
const o = x as ButtonObject;
|
||||
return (
|
||||
!!o &&
|
||||
o.kind === 'button' &&
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.name === 'string' &&
|
||||
Number.isFinite(o.x) &&
|
||||
Number.isFinite(o.y) &&
|
||||
Number.isFinite(o.width) &&
|
||||
Number.isFinite(o.height) &&
|
||||
typeof o.label === 'string'
|
||||
// macroId is intentionally NOT checked against a real macro here --
|
||||
// same lenient "dangling id renders as unassigned" convention as a
|
||||
// chart's sourceTableId, see ButtonObject's doc comment.
|
||||
);
|
||||
}
|
||||
|
||||
function normaliseTableView(v: Partial<TableView> | undefined, rows: number, cols: number): TableView {
|
||||
const d = defaultTableView();
|
||||
const mode = v?.mode === 'fixed' ? 'fixed' : 'auto';
|
||||
return {
|
||||
mode,
|
||||
width: Number.isFinite(v?.width) ? Math.max(200, Math.round(v!.width!)) : d.width,
|
||||
height: Number.isFinite(v?.height) ? Math.max(120, Math.round(v!.height!)) : d.height,
|
||||
frozenRows: Math.min(Math.max(0, Math.floor(v?.frozenRows ?? 0)), Math.max(0, rows - 1)),
|
||||
frozenCols: Math.min(Math.max(0, Math.floor(v?.frozenCols ?? 0)), Math.max(0, cols - 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseObject(o: SheetObject): SheetObject {
|
||||
if (o.kind === 'table') {
|
||||
const rows = Math.max(1, Math.floor(o.rows));
|
||||
const cols = Math.max(1, Math.floor(o.cols));
|
||||
return {
|
||||
kind: 'table',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
rows,
|
||||
cols,
|
||||
colWidths: numMapOrUndef(o.colWidths) ?? {},
|
||||
rowHeight:
|
||||
typeof o.rowHeight === 'number' && Number.isFinite(o.rowHeight)
|
||||
? Math.min(240, Math.max(14, Math.round(o.rowHeight)))
|
||||
: undefined,
|
||||
rowHeights: normaliseRowHeights(o.rowHeights),
|
||||
cells: { ...o.cells },
|
||||
formats: normaliseFormats(o.formats),
|
||||
view: normaliseTableView(o.view, rows, cols),
|
||||
locale: localeOrUndef(o.locale),
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
hideChromeUnfocused: boolOrUndef(o.hideChromeUnfocused),
|
||||
};
|
||||
}
|
||||
if (o.kind === 'text') {
|
||||
return {
|
||||
kind: 'text',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(80, Math.round(o.width)),
|
||||
height: Math.max(32, Math.round(o.height)),
|
||||
text: String(o.text ?? ''),
|
||||
fontSize: clampNum(o.fontSize, 9, 96, 15),
|
||||
align: alignOrUndef(o.align) ?? 'left',
|
||||
bold: o.bold === true || undefined,
|
||||
italic: o.italic === true || undefined,
|
||||
color: colorOrUndef(o.color),
|
||||
bg: colorOrUndef(o.bg),
|
||||
};
|
||||
}
|
||||
if (o.kind === 'button') {
|
||||
return {
|
||||
kind: 'button',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(60, Math.round(o.width)),
|
||||
height: Math.max(28, Math.round(o.height)),
|
||||
label: String(o.label ?? ''),
|
||||
macroId: typeof o.macroId === 'string' ? o.macroId : null,
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
};
|
||||
}
|
||||
if (o.kind === 'chart') {
|
||||
return {
|
||||
kind: 'chart',
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
x: Math.max(0, Math.round(o.x)),
|
||||
y: Math.max(0, Math.round(o.y)),
|
||||
width: Math.max(200, Math.round(o.width)),
|
||||
height: Math.max(140, Math.round(o.height)),
|
||||
view: o.view,
|
||||
sourceTableId: o.sourceTableId ?? null,
|
||||
locale: localeOrUndef(o.locale),
|
||||
bg: colorOrUndef(o.bg),
|
||||
fg: colorOrUndef(o.fg),
|
||||
};
|
||||
}
|
||||
// Every SheetObject kind is handled above -- reachable only if a new kind
|
||||
// is added to the union without a branch here (a compile-time error at
|
||||
// the call site would already catch that; this is the runtime backstop).
|
||||
throw new JsonImportError('Unbekannte Objektart.');
|
||||
}
|
||||
|
||||
export function validateDocument(x: unknown): DocumentState {
|
||||
if (!x || typeof x !== 'object') throw new JsonImportError('Keine gültige JSON-Struktur.');
|
||||
const d = x as { version?: unknown };
|
||||
|
||||
if (d.version === 1) return migrateToV2(x);
|
||||
if (d.version !== 2) throw new JsonImportError(`Nicht unterstützte Version: ${String(d.version)}`);
|
||||
|
||||
const doc = x as DocumentState;
|
||||
if (!Array.isArray(doc.sheets) || doc.sheets.length === 0)
|
||||
throw new JsonImportError('Dokument enthält keine Blätter.');
|
||||
|
||||
const sheetIds = new Set<string>();
|
||||
const objectIds = new Set<string>();
|
||||
const tableIds = new Set<string>();
|
||||
for (const s of doc.sheets) {
|
||||
if (!s || typeof s.id !== 'string' || typeof s.name !== 'string' || !Array.isArray(s.objects))
|
||||
throw new JsonImportError('Mindestens ein Blatt ist fehlerhaft.');
|
||||
if (sheetIds.has(s.id)) throw new JsonImportError('Doppelte Blatt-ID.');
|
||||
sheetIds.add(s.id);
|
||||
for (const o of s.objects) {
|
||||
if (!isTableObject(o) && !isChartObject(o) && !isTextObject(o) && !isButtonObject(o))
|
||||
throw new JsonImportError('Mindestens ein Objekt ist fehlerhaft.');
|
||||
if (objectIds.has(o.id)) throw new JsonImportError('Doppelte Objekt-ID.');
|
||||
objectIds.add(o.id);
|
||||
if (isTableObject(o)) tableIds.add(o.id);
|
||||
}
|
||||
}
|
||||
if (typeof doc.activeSheetId !== 'string' || !sheetIds.has(doc.activeSheetId))
|
||||
throw new JsonImportError('activeSheetId zeigt auf kein vorhandenes Blatt.');
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
sheets: doc.sheets.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
objects: s.objects.map(normaliseObject),
|
||||
locale: localeOrUndef(s.locale),
|
||||
background: colorOrUndef(s.background),
|
||||
foreground: colorOrUndef(s.foreground),
|
||||
showGrid: boolOrUndef(s.showGrid),
|
||||
gridSize: gridSizeOrUndef(s.gridSize),
|
||||
zoom: zoomOrUndef(s.zoom),
|
||||
})),
|
||||
activeSheetId: doc.activeSheetId,
|
||||
activeObjectId:
|
||||
doc.activeObjectId && objectIds.has(doc.activeObjectId) ? doc.activeObjectId : null,
|
||||
selection:
|
||||
doc.selection && objectIds.has(doc.selection.tableId)
|
||||
? normaliseSelection(doc.selection)
|
||||
: null,
|
||||
locale: isNumberLocale(doc.locale) ? doc.locale : DEFAULT_LOCALE,
|
||||
csvDelimiter: doc.csvDelimiter === ';' ? ';' : ',',
|
||||
customFunctions: normaliseCustomFunctions(doc.customFunctions),
|
||||
namedRanges: normaliseNamedRanges(doc.namedRanges, tableIds),
|
||||
macros: normaliseMacros(doc.macros),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- v1 -> v2 migration --------------------------------------------
|
||||
|
||||
interface V1Sheet {
|
||||
id: string;
|
||||
name: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
colWidths?: Record<number, number>;
|
||||
cells: Record<string, string>;
|
||||
}
|
||||
interface V1Doc {
|
||||
version: 1;
|
||||
sheets: V1Sheet[];
|
||||
activeSheetId: string;
|
||||
selection?: { sheetId: string; anchor: { col: number; row: number }; focus: { col: number; row: number } } | null;
|
||||
}
|
||||
|
||||
let migSeq = 0;
|
||||
const migId = (p: string) => `${p}-mig-${Date.now().toString(36)}-${migSeq++}`;
|
||||
|
||||
export function migrateToV2(x: unknown): DocumentState {
|
||||
const v1 = x as V1Doc;
|
||||
if (!Array.isArray(v1.sheets) || v1.sheets.length === 0)
|
||||
throw new JsonImportError('v1-Dokument enthält keine Tabellen.');
|
||||
|
||||
const sheetIdMap = new Map<string, { sheetId: string; tableId: string }>();
|
||||
const sheets: Sheet[] = v1.sheets.map((s) => {
|
||||
const sheetId = migId('sheet');
|
||||
const tableId = migId('tbl');
|
||||
sheetIdMap.set(s.id, { sheetId, tableId });
|
||||
const table: TableObject = {
|
||||
kind: 'table',
|
||||
id: tableId,
|
||||
name: s.name, // keep the name -> cross-sheet formulas still resolve
|
||||
x: 40,
|
||||
y: 40,
|
||||
rows: Math.max(1, Math.floor(s.rows ?? 20)),
|
||||
cols: Math.max(1, Math.floor(s.cols ?? 8)),
|
||||
colWidths: { ...(s.colWidths ?? {}) },
|
||||
cells: { ...(s.cells ?? {}) },
|
||||
view: defaultTableView(),
|
||||
};
|
||||
return { id: sheetId, name: s.name, objects: [table] };
|
||||
});
|
||||
|
||||
const active = sheetIdMap.get(v1.activeSheetId) ?? [...sheetIdMap.values()][0];
|
||||
const selSheet = v1.selection ? sheetIdMap.get(v1.selection.sheetId) : undefined;
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
sheets,
|
||||
activeSheetId: active.sheetId,
|
||||
activeObjectId: active.tableId,
|
||||
selection:
|
||||
v1.selection && selSheet
|
||||
? { tableId: selSheet.tableId, anchor: v1.selection.anchor, focus: v1.selection.focus }
|
||||
: null,
|
||||
locale: DEFAULT_LOCALE,
|
||||
csvDelimiter: ',',
|
||||
customFunctions: [],
|
||||
namedRanges: [],
|
||||
macros: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---- (de)serialise ------------------------------------------------
|
||||
|
||||
export function serializeDocument(doc: DocumentState): string {
|
||||
return JSON.stringify(doc, null, 2);
|
||||
}
|
||||
|
||||
export function parseDocument(text: string): DocumentState {
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new JsonImportError('Datei ist kein gültiges JSON.');
|
||||
}
|
||||
return validateDocument(data);
|
||||
}
|
||||
|
||||
/** Give imported sheets & their objects fresh ids so they can be appended. */
|
||||
export function reidSheets(doc: DocumentState): Sheet[] {
|
||||
return doc.sheets.map((s, i) => {
|
||||
const idMap = new Map<string, string>();
|
||||
const objects = s.objects.map((o, j) => {
|
||||
const nid = `imp-${Date.now().toString(36)}-${i}-${j}`;
|
||||
idMap.set(o.id, nid);
|
||||
return { ...o, id: nid } as SheetObject;
|
||||
});
|
||||
// repoint chart sources that referenced a sibling table in the same import
|
||||
for (const o of objects) {
|
||||
if (o.kind === 'chart' && o.sourceTableId && idMap.has(o.sourceTableId)) {
|
||||
o.sourceTableId = idMap.get(o.sourceTableId)!;
|
||||
}
|
||||
}
|
||||
return { id: `imp-sheet-${Date.now().toString(36)}-${i}`, name: s.name, objects };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The browser's document library: every numolus document the user has in
|
||||
* this browser, stored in IndexedDB. Two object stores, so listing documents
|
||||
* never has to read their (possibly large) contents:
|
||||
*
|
||||
* meta { id, title, createdAt, updatedAt } -- the document list
|
||||
* content { id, json } -- the document itself
|
||||
*
|
||||
* `json` is the serialised DocumentState (the same JSON a .num file holds).
|
||||
* Plain promise wrapper over the IndexedDB API, no library; `factory` is
|
||||
* injectable so tests can run against an in-memory implementation.
|
||||
*/
|
||||
|
||||
export interface DocMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
/** epoch ms */
|
||||
createdAt: number;
|
||||
/** epoch ms of the last *content* change (not cursor moves) */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface ContentRecord {
|
||||
id: string;
|
||||
json: string;
|
||||
}
|
||||
|
||||
const DB_NAME = 'numolus';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
function req<T>(r: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
r.onsuccess = () => resolve(r.result);
|
||||
r.onerror = () => reject(r.error);
|
||||
});
|
||||
}
|
||||
|
||||
function done(tx: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error ?? new Error('Transaktion abgebrochen.'));
|
||||
});
|
||||
}
|
||||
|
||||
export class DocumentLibrary {
|
||||
private constructor(private db: IDBDatabase) {}
|
||||
|
||||
static async open(factory: IDBFactory = indexedDB): Promise<DocumentLibrary> {
|
||||
const r = factory.open(DB_NAME, DB_VERSION);
|
||||
r.onupgradeneeded = () => {
|
||||
const db = r.result;
|
||||
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains('content')) db.createObjectStore('content', { keyPath: 'id' });
|
||||
};
|
||||
const db = await req(r);
|
||||
// another tab upgrading the schema later must not be blocked by us
|
||||
db.onversionchange = () => db.close();
|
||||
return new DocumentLibrary(db);
|
||||
}
|
||||
|
||||
/** All documents, most recently changed first. */
|
||||
async list(): Promise<DocMeta[]> {
|
||||
const tx = this.db.transaction('meta', 'readonly');
|
||||
const all = await req(tx.objectStore('meta').getAll() as IDBRequest<DocMeta[]>);
|
||||
return all.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
async getMeta(id: string): Promise<DocMeta | undefined> {
|
||||
const tx = this.db.transaction('meta', 'readonly');
|
||||
return req(tx.objectStore('meta').get(id) as IDBRequest<DocMeta | undefined>);
|
||||
}
|
||||
|
||||
async getContent(id: string): Promise<string | undefined> {
|
||||
const tx = this.db.transaction('content', 'readonly');
|
||||
const rec = await req(tx.objectStore('content').get(id) as IDBRequest<ContentRecord | undefined>);
|
||||
return rec?.json;
|
||||
}
|
||||
|
||||
/** Create or overwrite a document -- meta and content in one transaction. */
|
||||
async put(meta: DocMeta, json: string): Promise<void> {
|
||||
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
|
||||
tx.objectStore('meta').put(meta);
|
||||
tx.objectStore('content').put({ id: meta.id, json } satisfies ContentRecord);
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
/** Update only the listing data (rename). No-op for an unknown id. */
|
||||
async patchMeta(id: string, patch: Partial<Omit<DocMeta, 'id'>>): Promise<void> {
|
||||
const tx = this.db.transaction('meta', 'readwrite');
|
||||
const store = tx.objectStore('meta');
|
||||
const cur = await req(store.get(id) as IDBRequest<DocMeta | undefined>);
|
||||
if (cur) store.put({ ...cur, ...patch, id });
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
|
||||
tx.objectStore('meta').delete(id);
|
||||
tx.objectStore('content').delete(id);
|
||||
await done(tx);
|
||||
}
|
||||
}
|
||||
|
||||
export function newDocId(): string {
|
||||
// randomUUID exists only in secure contexts (https, localhost)
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
const b = crypto.getRandomValues(new Uint8Array(16));
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The native `.num` document file: a ZIP container around the same JSON the
|
||||
* `.json` export writes (json.ts), so it's smaller on disk and leaves room
|
||||
* for more parts later (images, attachments) without changing the format.
|
||||
*
|
||||
* Layout, modelled on OpenDocument:
|
||||
* mimetype "application/x-numolus", stored uncompressed as the
|
||||
* first entry, so a file sniffer can identify the format
|
||||
* from a fixed offset
|
||||
* meta.json { format, formatVersion, created (ISO 8601) }
|
||||
* document.json the document, exactly as `serializeDocument()` writes it
|
||||
*
|
||||
* Reading only needs `document.json`; `mimetype` is checked when present so
|
||||
* a random ZIP renamed to `.num` fails with a clear message.
|
||||
*/
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
|
||||
|
||||
export const NUM_MIMETYPE = 'application/x-numolus';
|
||||
export const NUM_FORMAT_VERSION = 1;
|
||||
|
||||
export class NumFileError extends Error {}
|
||||
|
||||
export function packNum(documentJson: string, now: Date = new Date()): Uint8Array {
|
||||
const meta = { format: 'numolus', formatVersion: NUM_FORMAT_VERSION, created: now.toISOString() };
|
||||
return zipSync({
|
||||
mimetype: [strToU8(NUM_MIMETYPE), { level: 0 }],
|
||||
'meta.json': strToU8(JSON.stringify(meta, null, 2)),
|
||||
'document.json': strToU8(documentJson),
|
||||
});
|
||||
}
|
||||
|
||||
/** The `document.json` text inside a `.num` file. */
|
||||
export function unpackNum(bytes: Uint8Array): string {
|
||||
let files: Record<string, Uint8Array>;
|
||||
try {
|
||||
files = unzipSync(bytes);
|
||||
} catch {
|
||||
throw new NumFileError('Keine gültige .num-Datei (kein ZIP-Archiv).');
|
||||
}
|
||||
const mimetype = files.mimetype ? strFromU8(files.mimetype).trim() : null;
|
||||
if (mimetype !== null && mimetype !== NUM_MIMETYPE) {
|
||||
throw new NumFileError(`Keine numolus-Datei (Typ "${mimetype}").`);
|
||||
}
|
||||
const doc = files['document.json'];
|
||||
if (!doc) throw new NumFileError('Die .num-Datei enthält kein document.json.');
|
||||
const meta = files['meta.json'] ? safeJson(strFromU8(files['meta.json'])) : null;
|
||||
const version = meta && typeof meta === 'object' ? (meta as { formatVersion?: unknown }).formatVersion : undefined;
|
||||
if (typeof version === 'number' && version > NUM_FORMAT_VERSION) {
|
||||
throw new NumFileError(
|
||||
`Die Datei stammt aus einer neueren numolus-Version (Format ${version}); bitte die App aktualisieren.`,
|
||||
);
|
||||
}
|
||||
return strFromU8(doc);
|
||||
}
|
||||
|
||||
function safeJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* High-level import/export operations. Each one is atomic: on any failure it
|
||||
* throws and leaves the document untouched (the UI catches and shows a toast).
|
||||
*/
|
||||
|
||||
import type { DocumentStore } from '../store/document';
|
||||
import { downloadBlob, downloadText } from './download';
|
||||
import { serializeDocument, parseDocument, reidSheets } from './json';
|
||||
import {
|
||||
parseCsv,
|
||||
sniffDelimiter,
|
||||
matrixToCells,
|
||||
tableToCsv,
|
||||
type Delimiter,
|
||||
type CsvExportOptions,
|
||||
} from './csv';
|
||||
import { documentToXlsx, xlsxToSheets, importedToSheet } from './xlsx';
|
||||
|
||||
export function readAsText(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
|
||||
fr.onload = () => resolve(String(fr.result));
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
|
||||
fr.onload = () => resolve(fr.result as ArrayBuffer);
|
||||
fr.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function extensionOf(name: string): string {
|
||||
const i = name.lastIndexOf('.');
|
||||
return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
/** Yield to the event loop so a large parse doesn't freeze the UI. */
|
||||
const breathe = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// ---- export ---------------------------------------------------------
|
||||
|
||||
/** A document title as a file name (without extension). */
|
||||
export function fileBaseName(title: string): string {
|
||||
return title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-').trim() || 'dokument';
|
||||
}
|
||||
|
||||
export function exportJson(store: DocumentStore, name = 'dokument'): void {
|
||||
downloadText(`${fileBaseName(name)}.json`, serializeDocument(store.snapshot()), 'application/json');
|
||||
}
|
||||
|
||||
/** Save as the native `.num` file (the JSON document, zipped -- see num.ts).
|
||||
* The zip code loads on first use, like the xlsx support. */
|
||||
export async function exportNum(store: DocumentStore, name = 'dokument'): Promise<void> {
|
||||
const { packNum, NUM_MIMETYPE } = await import('./num');
|
||||
const bytes = packNum(serializeDocument(store.snapshot()));
|
||||
downloadBlob(`${fileBaseName(name)}.num`, new Blob([bytes], { type: NUM_MIMETYPE }));
|
||||
}
|
||||
|
||||
/** The document JSON inside a `.num` file -- then imported like a `.json`. */
|
||||
export async function readNumFile(buffer: ArrayBuffer): Promise<string> {
|
||||
const { unpackNum } = await import('./num');
|
||||
return unpackNum(new Uint8Array(buffer));
|
||||
}
|
||||
|
||||
export function exportActiveTableCsv(store: DocumentStore, opts: CsvExportOptions): void {
|
||||
const table = store.activeTable.value;
|
||||
if (!table) throw new Error('Keine Tabelle ausgewählt.');
|
||||
downloadText(`${table.name}.csv`, tableToCsv(store, table.id, opts), 'text/csv;charset=utf-8');
|
||||
}
|
||||
|
||||
export async function exportXlsx(store: DocumentStore, name = 'arbeitsmappe'): Promise<void> {
|
||||
downloadBlob(`${fileBaseName(name)}.xlsx`, await documentToXlsx(store));
|
||||
}
|
||||
|
||||
// ---- import: JSON ------------------------------------------------
|
||||
|
||||
export function importJsonReplace(store: DocumentStore, text: string): void {
|
||||
store.replaceDocument(parseDocument(text)); // throws JsonImportError on bad schema
|
||||
}
|
||||
|
||||
export function importJsonAppend(store: DocumentStore, text: string): void {
|
||||
store.appendSheets(reidSheets(parseDocument(text)));
|
||||
}
|
||||
|
||||
// ---- import: CSV -----------------------------------------------
|
||||
|
||||
export interface CsvImportConfig {
|
||||
mode: 'new-table' | 'replace-active';
|
||||
delimiter: Delimiter | 'auto';
|
||||
detectNumbers: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function importCsv(
|
||||
store: DocumentStore,
|
||||
text: string,
|
||||
cfg: CsvImportConfig,
|
||||
): Promise<void> {
|
||||
const delimiter: Delimiter = cfg.delimiter === 'auto' ? sniffDelimiter(text) : cfg.delimiter;
|
||||
const matrix = parseCsv(text, delimiter);
|
||||
if (matrix.length > 2000) await breathe();
|
||||
const { cells, rows, cols } = matrixToCells(matrix, cfg.detectNumbers);
|
||||
|
||||
if (cfg.mode === 'replace-active') {
|
||||
const table = store.activeTable.value;
|
||||
if (!table) throw new Error('Keine Zieltabelle ausgewählt.');
|
||||
store.replaceTableCells(table.id, cells, Math.max(rows, 12), Math.max(cols, 6));
|
||||
} else {
|
||||
store.addTableFromCells(
|
||||
store.doc.activeSheetId,
|
||||
cfg.name?.trim() || 'Import',
|
||||
cells,
|
||||
Math.max(rows, 12),
|
||||
Math.max(cols, 6),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- import: XLSX --------------------------------------------
|
||||
|
||||
export async function importXlsxFile(store: DocumentStore, buffer: ArrayBuffer): Promise<void> {
|
||||
const imported = await xlsxToSheets(buffer);
|
||||
if (imported.length === 0) throw new Error('Die Arbeitsmappe enthält keine Tabellen.');
|
||||
await breathe();
|
||||
store.appendSheets(imported.map((s, i) => importedToSheet(s, i)));
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* .xlsx import/export via SheetJS (the *only* place a spreadsheet library is
|
||||
* used, and never for formula evaluation -- that stays in our engine).
|
||||
*
|
||||
* export: one worksheet per table. Formula cells are written as real Excel
|
||||
* formulas when they translate cleanly (`;` -> `,`, `AVG` ->
|
||||
* `AVERAGE`), always accompanied by the cached value from our engine.
|
||||
* import: one table per worksheet. `cell.f` becomes a formula when our parser
|
||||
* accepts it, otherwise the cached value is taken.
|
||||
*/
|
||||
|
||||
import type * as XLSXNS from 'xlsx';
|
||||
import { parseFormula } from '../engine';
|
||||
import type { DocumentStore } from '../store/document';
|
||||
import type { Sheet, TableObject } from '../store/types';
|
||||
import { defaultTableView } from '../store/types';
|
||||
import { rawString } from '../store/format';
|
||||
|
||||
/** SheetJS is the only spreadsheet library and it is loaded on demand. */
|
||||
let xlsxModule: typeof XLSXNS | null = null;
|
||||
async function loadXLSX(): Promise<typeof XLSXNS> {
|
||||
if (!xlsxModule) xlsxModule = await import('xlsx');
|
||||
return xlsxModule;
|
||||
}
|
||||
|
||||
/** Swap top-level separators while respecting "..." and '...' quoting. */
|
||||
function swapOutsideQuotes(src: string, from: string, to: string): string {
|
||||
let out = '';
|
||||
let q: '"' | "'" | null = null;
|
||||
for (const ch of src) {
|
||||
if (q) {
|
||||
out += ch;
|
||||
if (ch === q) q = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
q = ch;
|
||||
out += ch;
|
||||
} else if (ch === from) {
|
||||
out += to;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toExcelFormula(input: string): string | null {
|
||||
// input starts with '='
|
||||
let body = input.slice(1);
|
||||
body = swapOutsideQuotes(body, ';', ',');
|
||||
body = body.replace(/\bAVG\s*\(/gi, 'AVERAGE(');
|
||||
return body;
|
||||
}
|
||||
|
||||
function fromExcelFormula(f: string): string | null {
|
||||
const body = swapOutsideQuotes(f, ',', ';');
|
||||
const candidate = '=' + body;
|
||||
try {
|
||||
parseFormula(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeSheetName(name: string, taken: Set<string>): string {
|
||||
const clean = name.replace(/[[\]:*?/\\]/g, ' ').trim().slice(0, 31) || 'Tabelle';
|
||||
let candidate = clean;
|
||||
let i = 2;
|
||||
while (taken.has(candidate.toLowerCase())) {
|
||||
candidate = `${clean.slice(0, 28)} ${i++}`;
|
||||
}
|
||||
taken.add(candidate.toLowerCase());
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// ---- export -----------------------------------------------------------
|
||||
|
||||
export async function documentToXlsx(store: DocumentStore): Promise<Blob> {
|
||||
const XLSX = await loadXLSX();
|
||||
const wb = XLSX.utils.book_new();
|
||||
const taken = new Set<string>();
|
||||
|
||||
// one worksheet per table object, across all sheets
|
||||
const tables = store.doc.sheets.flatMap((s) =>
|
||||
s.objects.filter((o): o is TableObject => o.kind === 'table'),
|
||||
);
|
||||
for (const table of tables) {
|
||||
const ws: XLSXNS.WorkSheet = {};
|
||||
let maxC = 0;
|
||||
let maxR = 0;
|
||||
|
||||
for (const key of Object.keys(table.cells)) {
|
||||
const [c, r] = key.split(',').map(Number);
|
||||
const raw = table.cells[key];
|
||||
const addr = XLSX.utils.encode_cell({ c, r });
|
||||
const value = store.cellValue(table.id, c, r);
|
||||
|
||||
if (raw.startsWith('=')) {
|
||||
const f = toExcelFormula(raw);
|
||||
const num = typeof value === 'number' ? value : undefined;
|
||||
ws[addr] =
|
||||
num !== undefined
|
||||
? { t: 'n', v: num, f: f ?? undefined }
|
||||
: { t: 's', v: rawString(value), f: f ?? undefined };
|
||||
} else if (typeof value === 'number') {
|
||||
ws[addr] = { t: 'n', v: value };
|
||||
} else {
|
||||
ws[addr] = { t: 's', v: raw };
|
||||
}
|
||||
if (c > maxC) maxC = c;
|
||||
if (r > maxR) maxR = r;
|
||||
}
|
||||
|
||||
ws['!ref'] = XLSX.utils.encode_range({ s: { c: 0, r: 0 }, e: { c: maxC, r: maxR } });
|
||||
XLSX.utils.book_append_sheet(wb, ws, sanitizeSheetName(table.name, taken));
|
||||
}
|
||||
|
||||
const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer;
|
||||
return new Blob([out], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
}
|
||||
|
||||
// ---- import -----------------------------------------------------------
|
||||
|
||||
export interface ImportedSheet {
|
||||
name: string;
|
||||
rows: number;
|
||||
cols: number;
|
||||
cells: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function xlsxToSheets(buffer: ArrayBuffer): Promise<ImportedSheet[]> {
|
||||
const XLSX = await loadXLSX();
|
||||
const wb = XLSX.read(buffer, { type: 'array', cellFormula: true, cellNF: false });
|
||||
const result: ImportedSheet[] = [];
|
||||
|
||||
for (const name of wb.SheetNames) {
|
||||
const ws = wb.Sheets[name];
|
||||
const cells: Record<string, string> = {};
|
||||
let rows = 1;
|
||||
let cols = 1;
|
||||
if (ws && ws['!ref']) {
|
||||
const range = XLSX.utils.decode_range(ws['!ref']);
|
||||
for (let r = range.s.r; r <= range.e.r; r++) {
|
||||
for (let c = range.s.c; c <= range.e.c; c++) {
|
||||
const cell = ws[XLSX.utils.encode_cell({ r, c })] as XLSXNS.CellObject | undefined;
|
||||
if (!cell) continue;
|
||||
let raw: string | null = null;
|
||||
if (cell.f) raw = fromExcelFormula(String(cell.f));
|
||||
if (raw == null) {
|
||||
if (cell.v == null) continue;
|
||||
raw = cell.t === 'n' ? String(cell.v) : String(cell.w ?? cell.v);
|
||||
}
|
||||
if (raw === '') continue;
|
||||
cells[`${c},${r}`] = raw;
|
||||
rows = Math.max(rows, r + 1);
|
||||
cols = Math.max(cols, c + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push({ name, rows: Math.max(rows, 20), cols: Math.max(cols, 8), cells });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Wrap an imported worksheet as a canvas sheet holding one table object. */
|
||||
export function importedToSheet(imported: ImportedSheet, idx: number): Sheet {
|
||||
const stamp = Date.now().toString(36);
|
||||
const table: TableObject = {
|
||||
kind: 'table',
|
||||
id: `xlsx-tbl-${stamp}-${idx}`,
|
||||
name: imported.name,
|
||||
x: 40,
|
||||
y: 40,
|
||||
rows: imported.rows,
|
||||
cols: imported.cols,
|
||||
colWidths: {},
|
||||
cells: imported.cells,
|
||||
view: defaultTableView(),
|
||||
};
|
||||
return { id: `xlsx-sheet-${stamp}-${idx}`, name: imported.name, objects: [table] };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 197 93" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1,0,0,1,-230,0)">
|
||||
<g id="Artboard2" transform="matrix(1,0,0,1,-0.38331,-0.424444)">
|
||||
<rect x="230.383" y="0.424" width="196.322" height="92.247" style="fill:none;"/>
|
||||
<g transform="matrix(0.333333,0,0,0.935173,222.05,-23.7646)">
|
||||
<rect x="40" y="30" width="90" height="90" style="fill:rgb(255,212,0);"/>
|
||||
</g>
|
||||
<g transform="matrix(0.661788,0,0,0.932447,201.881,-23.4375)">
|
||||
<rect x="102" y="30" width="90" height="90" style="fill:rgb(0,87,255);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,0.934048,168.983,-23.6296)">
|
||||
<rect x="164" y="30" width="90" height="90" style="fill:rgb(232,56,79);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { dateToSerial, timeSerial } from '../../engine';
|
||||
import { formatValue } from '../format';
|
||||
|
||||
describe('formatValue: date/time', () => {
|
||||
const day = dateToSerial(2026, 9, 2); // a Wednesday
|
||||
const noon = timeSerial(12, 0, 0);
|
||||
const dayAtNoon = day + noon;
|
||||
|
||||
it('renders a date per locale', () => {
|
||||
expect(formatValue(day, 'de-DE', 'date')).toBe('02.09.2026');
|
||||
expect(formatValue(day, 'en-US', 'date')).toBe('09/02/2026');
|
||||
expect(formatValue(day, 'plain', 'date')).toBe('2026-09-02');
|
||||
});
|
||||
|
||||
it('renders a time per locale', () => {
|
||||
expect(formatValue(noon, 'de-DE', 'time')).toBe('12:00');
|
||||
expect(formatValue(noon, 'plain', 'time')).toBe('12:00');
|
||||
});
|
||||
|
||||
it('renders date + time together', () => {
|
||||
expect(formatValue(dayAtNoon, 'de-DE', 'datetime')).toBe('02.09.2026 12:00');
|
||||
expect(formatValue(dayAtNoon, 'plain', 'datetime')).toBe('2026-09-02 12:00');
|
||||
});
|
||||
|
||||
it('date + time shows only the time for a bare time of day (no 1899 date)', () => {
|
||||
expect(formatValue(timeSerial(12, 23, 0), 'de-DE', 'datetime')).toBe('12:23');
|
||||
});
|
||||
|
||||
it('"duration" keeps counting hours past 24', () => {
|
||||
expect(formatValue(37.5 / 24, 'de-DE', 'duration')).toBe('37:30');
|
||||
expect(formatValue(timeSerial(8, 5, 0), 'de-DE', 'duration')).toBe('08:05');
|
||||
expect(formatValue((100 * 3600 + 30) / 86400, 'de-DE', 'duration')).toBe('100:00:30');
|
||||
expect(formatValue(-1.5 / 24, 'de-DE', 'duration')).toBe('-01:30');
|
||||
expect(formatValue(0, 'de-DE', 'duration')).toBe('00:00');
|
||||
});
|
||||
|
||||
it('empty cell / error still short-circuit before any date formatting', () => {
|
||||
expect(formatValue(null, 'de-DE', 'date')).toBe('');
|
||||
});
|
||||
|
||||
it('"dateiso" renders YYYY-MM-DD regardless of the active locale', () => {
|
||||
expect(formatValue(day, 'de-DE', 'dateiso')).toBe('2026-09-02');
|
||||
expect(formatValue(day, 'en-US', 'dateiso')).toBe('2026-09-02');
|
||||
expect(formatValue(day, 'fr-FR', 'dateiso')).toBe('2026-09-02');
|
||||
expect(formatValue(day, 'plain', 'dateiso')).toBe('2026-09-02');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
import { isError, serialToUtcDate, type CellValue } from '../engine';
|
||||
import type { CellNumFmt } from './types';
|
||||
|
||||
/**
|
||||
* Locale used to render numeric values. `'plain'` is not a real locale -- it
|
||||
* renders the bare JS number (`.` decimal, no grouping).
|
||||
*
|
||||
* A *display* setting only. `rawString()` (CSV / XLSX export) stays
|
||||
* locale-neutral so round-trips are lossless.
|
||||
*/
|
||||
export type NumberLocale =
|
||||
| 'plain'
|
||||
| 'de-DE'
|
||||
| 'de-CH'
|
||||
| 'en-US'
|
||||
| 'en-GB'
|
||||
| 'fr-FR'
|
||||
| 'it-IT';
|
||||
|
||||
export const NUMBER_LOCALES: ReadonlyArray<{ id: NumberLocale; label: string }> = [
|
||||
{ id: 'plain', label: 'Ohne Trennzeichen · 1234.5' },
|
||||
{ id: 'de-DE', label: 'Deutsch (DE) · 1.234,5' },
|
||||
{ id: 'de-CH', label: 'Schweiz (CH) · 1’234.5' },
|
||||
{ id: 'en-US', label: 'English (US) · 1,234.5' },
|
||||
{ id: 'en-GB', label: 'English (UK) · 1,234.5' },
|
||||
{ id: 'fr-FR', label: 'Français (FR) · 1 234,5' },
|
||||
{ id: 'it-IT', label: 'Italiano (IT) · 1.234,5' },
|
||||
];
|
||||
|
||||
const LOCALE_IDS = new Set(NUMBER_LOCALES.map((l) => l.id));
|
||||
export function isNumberLocale(x: unknown): x is NumberLocale {
|
||||
return typeof x === 'string' && LOCALE_IDS.has(x as NumberLocale);
|
||||
}
|
||||
export const DEFAULT_LOCALE: NumberLocale = 'de-DE';
|
||||
|
||||
/** Cell number-format options offered in the UI. */
|
||||
export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
|
||||
{ id: 'auto', label: 'Automatisch' },
|
||||
{ id: 'plain', label: 'Ohne Formatierung' },
|
||||
{ id: 'int', label: 'Ganzzahl · 1.235' },
|
||||
{ id: 'dec1', label: '1 Nachkommastelle · 1.234,6' },
|
||||
{ id: 'dec2', label: '2 Nachkommastellen · 1.234,56' },
|
||||
{ id: 'pct0', label: 'Prozent · 12 %' },
|
||||
{ id: 'pct2', label: 'Prozent · 12,34 %' },
|
||||
{ id: 'date', label: 'Datum · 02.09.2026' },
|
||||
{ id: 'dateiso', label: 'Datum (ISO) · 2026-09-02' },
|
||||
{ id: 'time', label: 'Zeit · 14:30' },
|
||||
{ id: 'datetime', label: 'Datum + Zeit · 02.09.2026 14:30' },
|
||||
{ id: 'duration', label: 'Dauer · 37:30' },
|
||||
];
|
||||
const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id));
|
||||
export function isCellNumFmt(x: unknown): x is CellNumFmt {
|
||||
return typeof x === 'string' && NUM_FMT_IDS.has(x as CellNumFmt);
|
||||
}
|
||||
|
||||
const round10 = (n: number) => Math.round(n * 1e10) / 1e10;
|
||||
|
||||
function nf(locale: NumberLocale, opts: Intl.NumberFormatOptions): Intl.NumberFormat {
|
||||
// for the pseudo-locale 'plain' use en-US digits but turn grouping off
|
||||
return new Intl.NumberFormat(locale === 'plain' ? 'en-US' : locale, {
|
||||
useGrouping: locale !== 'plain',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** `YYYY-MM-DD`, hand-written rather than via `Intl` -- there's no locale
|
||||
* that reliably reproduces ISO 8601, and this is also what the `'plain'`
|
||||
* pseudo-locale uses for a date (an `Intl` call with an `undefined` locale
|
||||
* would silently fall back to the *browser's* locale instead of staying
|
||||
* neutral). Shared by the locale-independent `'dateiso'` cell format and
|
||||
* by `formatDatePart`'s `'plain'` branch below. */
|
||||
function isoDateString(d: Date): string {
|
||||
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
function formatDatePart(d: Date, locale: NumberLocale): string {
|
||||
if (locale === 'plain') return isoDateString(d);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
}).format(d);
|
||||
}
|
||||
function formatTimePart(d: Date, locale: NumberLocale): string {
|
||||
if (locale === 'plain') return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/** Elapsed time as `[h]:mm` -- hours keep counting past 24 (`37:30`), the
|
||||
* way a sum of working times should read; seconds only when there are any.
|
||||
* Locale-independent: `:` is the time separator in every supported locale. */
|
||||
function formatDuration(n: number): string {
|
||||
let sec = Math.round(Math.abs(n) * 86400);
|
||||
const h = Math.floor(sec / 3600);
|
||||
sec -= h * 3600;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec - m * 60;
|
||||
return `${n < 0 && (h || m || s) ? '-' : ''}${pad2(h)}:${pad2(m)}${s ? `:${pad2(s)}` : ''}`;
|
||||
}
|
||||
|
||||
/** Render a computed value for display (grid cell / status bar / chart). */
|
||||
export function formatValue(
|
||||
v: CellValue,
|
||||
locale: NumberLocale = DEFAULT_LOCALE,
|
||||
numFmt: CellNumFmt = 'auto',
|
||||
): string {
|
||||
if (v === null) return '';
|
||||
if (isError(v)) return v.code;
|
||||
if (typeof v !== 'number') return v;
|
||||
if (!Number.isFinite(v)) return '#NUM!';
|
||||
|
||||
const n = round10(v);
|
||||
switch (numFmt) {
|
||||
case 'plain':
|
||||
return String(n);
|
||||
case 'int':
|
||||
return nf(locale, { maximumFractionDigits: 0 }).format(n);
|
||||
case 'dec1':
|
||||
return nf(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n);
|
||||
case 'dec2':
|
||||
return nf(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
||||
case 'pct0':
|
||||
return nf(locale, { style: 'percent', maximumFractionDigits: 0 }).format(n);
|
||||
case 'pct2':
|
||||
return nf(locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n);
|
||||
case 'date':
|
||||
return formatDatePart(serialToUtcDate(n), locale);
|
||||
case 'dateiso':
|
||||
return isoDateString(serialToUtcDate(n)); // same regardless of locale
|
||||
case 'time':
|
||||
return formatTimePart(serialToUtcDate(n), locale);
|
||||
case 'datetime':
|
||||
// a bare time of day (< 1 day) has no calendar date -- showing the
|
||||
// epoch "30.12.1899" in front of it would be noise
|
||||
if (n >= 0 && n < 1) return formatTimePart(serialToUtcDate(n), locale);
|
||||
return `${formatDatePart(serialToUtcDate(n), locale)} ${formatTimePart(serialToUtcDate(n), locale)}`;
|
||||
case 'duration':
|
||||
return formatDuration(n);
|
||||
default: // 'auto'
|
||||
return locale === 'plain'
|
||||
? String(n)
|
||||
: nf(locale, { maximumFractionDigits: 10 }).format(n);
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain (ungrouped, `.`-decimal) string -- machine-readable, for CSV / XLSX. */
|
||||
export function rawString(v: CellValue): string {
|
||||
if (v === null) return '';
|
||||
if (isError(v)) return v.code;
|
||||
if (typeof v === 'number') return String(round10(v));
|
||||
return v;
|
||||
}
|
||||
|
||||
export function isNumeric(v: CellValue): boolean {
|
||||
return typeof v === 'number' && Number.isFinite(v);
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
/**
|
||||
* Glue between the document store (one open document) and where documents
|
||||
* live:
|
||||
*
|
||||
* - **local** documents: in this browser's IndexedDB (src/io/library.ts),
|
||||
* URL `/?doc=<id>`. Without an account, that's all there is.
|
||||
* - **server** documents: on the numolus server (src/io/api.ts), URL
|
||||
* `/d/<id>`. With an account, new documents are created there and local
|
||||
* ones can be uploaded; they can be shared. A share link
|
||||
* (`/d/<id>#k=<token>`) also opens a server document without an account --
|
||||
* the token is kept in this browser (localStorage) and sent along.
|
||||
*
|
||||
* Persistence: the store's debounced save lands in `persist()` and goes to
|
||||
* IndexedDB or to the server. Server documents have a single-editor lock
|
||||
* (until live collaboration exists): whoever holds it edits, everyone else
|
||||
* sees the document read-only and follows its changes by polling.
|
||||
*
|
||||
* Other tabs: a BroadcastChannel keeps lists current and lets a local
|
||||
* document open in two tabs follow the other tab's saves.
|
||||
*/
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import { useDocument, setPersistHandler, setReadOnlyNotifier, contentFingerprint } from './document';
|
||||
import type { DocumentState } from './types';
|
||||
import { DocumentLibrary, newDocId, type DocMeta } from '../io/library';
|
||||
import { api, ApiError, type Role, type ServerDoc, type ServerDocMeta } from '../io/api';
|
||||
import { validateDocument } from '../io/json';
|
||||
import { createBlankDocument, createSeedDocument } from '../data/seed';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import { useSession } from './session';
|
||||
|
||||
/** The single-document localStorage keys used before the library existed --
|
||||
* read once and moved into the library as its first document. */
|
||||
const LEGACY_KEYS = ['numolus:doc:v2', 'numbers-study:doc:v2'];
|
||||
/** share-link tokens this browser holds without an account */
|
||||
const LINKS_KEY = 'numolus:links';
|
||||
const CHANNEL = 'numolus:library';
|
||||
const DEFAULT_TITLE = 'Unbenannt';
|
||||
const APP_TITLE = 'numolus';
|
||||
const UPLOAD_DELAY = 800; // ms after the last change
|
||||
const HEARTBEAT = 30_000; // refresh the edit lock (server TTL is 90 s)
|
||||
const POLL = 10_000; // read-only: look for someone else's changes
|
||||
|
||||
export type DocSource = 'local' | 'server';
|
||||
export interface DocRef {
|
||||
source: DocSource;
|
||||
id: string;
|
||||
}
|
||||
export interface LinkDoc {
|
||||
id: string;
|
||||
token: string;
|
||||
title: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
type Msg =
|
||||
| { type: 'list' }
|
||||
| { type: 'saved'; id: string }
|
||||
| { type: 'deleted'; id: string }
|
||||
| { type: 'hello'; id: string }
|
||||
| { type: 'here'; id: string };
|
||||
|
||||
function metaOf(d: ServerDoc): ServerDocMeta {
|
||||
const { id, title, version, role, owner_name, updated_at, updated_by_name } = d;
|
||||
return { id, title, version, role, owner_name, updated_at, updated_by_name };
|
||||
}
|
||||
|
||||
function readLinks(): LinkDoc[] {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LINKS_KEY) || '[]') as LinkDoc[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function writeLinks(links: LinkDoc[]) {
|
||||
try {
|
||||
localStorage.setItem(LINKS_KEY, JSON.stringify(links));
|
||||
} catch {
|
||||
/* private mode -- the link itself still works */
|
||||
}
|
||||
}
|
||||
|
||||
function createLibrary() {
|
||||
const store = useDocument();
|
||||
const toasts = useToasts();
|
||||
const session = useSession();
|
||||
|
||||
const state = reactive({
|
||||
ready: false,
|
||||
/** false = IndexedDB unavailable (private mode in some browsers, ...) */
|
||||
available: true,
|
||||
/** local documents (IndexedDB) */
|
||||
docs: [] as DocMeta[],
|
||||
/** server documents of the signed-in user: own + shared with them */
|
||||
serverDocs: [] as ServerDocMeta[],
|
||||
/** documents opened via share link without an account */
|
||||
linkDocs: readLinks(),
|
||||
current: null as DocRef | null,
|
||||
/** the open server document's metadata (title, role, version, ...) */
|
||||
server: null as ServerDocMeta | null,
|
||||
/** someone else holds the edit lock on the open server document */
|
||||
lockedBy: null as string | null,
|
||||
/** server saves waiting or failing */
|
||||
syncState: 'idle' as 'idle' | 'saving' | 'offline',
|
||||
});
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
const c = state.current;
|
||||
if (!c) return DEFAULT_TITLE;
|
||||
if (c.source === 'server') return state.server?.title ?? DEFAULT_TITLE;
|
||||
return state.docs.find((d) => d.id === c.id)?.title ?? DEFAULT_TITLE;
|
||||
});
|
||||
/** role in the open document; local documents are always the user's own */
|
||||
const currentRole = computed<Role>(() =>
|
||||
state.current?.source === 'server' ? (state.server?.role ?? 'viewer') : 'owner',
|
||||
);
|
||||
const canEdit = computed(() => currentRole.value !== 'viewer' && !state.lockedBy);
|
||||
|
||||
let lib: DocumentLibrary | null = null;
|
||||
let channel: BroadcastChannel | null = null;
|
||||
/** content fingerprint of what's saved for the open document */
|
||||
let savedContent = '';
|
||||
/** `updatedAt` of the open local document as this tab last wrote or read it */
|
||||
let knownUpdatedAt = 0;
|
||||
/** local writes run one after another, in order */
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
const warnedShared = new Set<string>();
|
||||
|
||||
// server document state
|
||||
let uploadTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let uploading: Promise<void> | null = null;
|
||||
let pendingJson: string | null = null;
|
||||
let pendingFp = '';
|
||||
let holdsLock = false;
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const post = (m: Msg) => channel?.postMessage(m);
|
||||
const enqueue = (job: () => Promise<void>) => {
|
||||
writeQueue = writeQueue.then(job).catch((err) => {
|
||||
console.error(err);
|
||||
toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`);
|
||||
});
|
||||
return writeQueue;
|
||||
};
|
||||
const shareToken = (id: string) => state.linkDocs.find((l) => l.id === id)?.token ?? null;
|
||||
const isCurrent = (ref: DocRef) => state.current?.source === ref.source && state.current.id === ref.id;
|
||||
|
||||
async function refreshList() {
|
||||
if (lib) state.docs = await lib.list();
|
||||
}
|
||||
async function refreshServerList() {
|
||||
if (!session.signedIn.value) return;
|
||||
try {
|
||||
state.serverDocs = (await api<{ documents: ServerDocMeta[] }>('GET', '/documents')).documents;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueTitle(base: string, taken: string[]): string {
|
||||
const set = new Set(taken.map((t) => t.toLowerCase()));
|
||||
if (!set.has(base.toLowerCase())) return base;
|
||||
for (let n = 2; ; n++) {
|
||||
const t = `${base} ${n}`;
|
||||
if (!set.has(t.toLowerCase())) return t;
|
||||
}
|
||||
}
|
||||
|
||||
function setUrl(ref: DocRef, push: boolean) {
|
||||
const url = new URL(location.href);
|
||||
url.hash = '';
|
||||
if (ref.source === 'server') {
|
||||
url.pathname = `/d/${ref.id}`;
|
||||
url.searchParams.delete('doc');
|
||||
} else {
|
||||
url.pathname = '/';
|
||||
url.searchParams.set('doc', ref.id);
|
||||
}
|
||||
if (url.href === location.href) return;
|
||||
if (push) history.pushState(null, '', url);
|
||||
else history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
function updateWindowTitle() {
|
||||
document.title = `${currentTitle.value} – ${APP_TITLE}`;
|
||||
}
|
||||
|
||||
function applyReadOnly() {
|
||||
store.ui.readOnly =
|
||||
state.current?.source !== 'server'
|
||||
? null
|
||||
: currentRole.value === 'viewer'
|
||||
? 'Nur Lesen – du kannst dieses Dokument ansehen, aber nicht bearbeiten.'
|
||||
: state.lockedBy
|
||||
? `${state.lockedBy} bearbeitet gerade – nur Lesen, bis die Bearbeitung beendet ist.`
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Put `next` into the store as the open document. */
|
||||
function show(ref: DocRef, next: DocumentState, keepView = false) {
|
||||
if (keepView && next.sheets.some((s) => s.id === store.doc.activeSheetId)) {
|
||||
// someone else's save: take the content, keep this tab's cursor/sheet
|
||||
next.activeSheetId = store.doc.activeSheetId;
|
||||
next.activeObjectId = store.doc.activeObjectId;
|
||||
next.selection = store.doc.selection;
|
||||
}
|
||||
state.current = ref;
|
||||
store.replaceDocument(next);
|
||||
// it came from where it's saved -- writing it straight back is pointless
|
||||
store.cancelSave();
|
||||
savedContent = contentFingerprint(store.doc);
|
||||
updateWindowTitle();
|
||||
}
|
||||
|
||||
// ---- persistence -----------------------------------------------------
|
||||
function persist(doc: DocumentState) {
|
||||
const c = state.current;
|
||||
if (!c) return;
|
||||
if (c.source === 'server') return persistServer(doc);
|
||||
if (!lib) return;
|
||||
const id = c.id;
|
||||
const json = JSON.stringify(doc);
|
||||
const fp = contentFingerprint(doc);
|
||||
const changed = fp !== savedContent;
|
||||
savedContent = fp;
|
||||
void enqueue(async () => {
|
||||
const meta = (await lib!.getMeta(id)) ?? {
|
||||
id,
|
||||
title: currentTitle.value,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (!changed) {
|
||||
// only the cursor/active sheet moved: saved so reopening restores
|
||||
// it, but never over newer content another tab has written meanwhile
|
||||
if (meta.updatedAt !== knownUpdatedAt) return;
|
||||
await lib!.put(meta, json);
|
||||
return;
|
||||
}
|
||||
const updatedAt = Math.max(Date.now(), meta.updatedAt + 1);
|
||||
await lib!.put({ ...meta, updatedAt }, json);
|
||||
if (isCurrent({ source: 'local', id })) knownUpdatedAt = updatedAt;
|
||||
await refreshList();
|
||||
post({ type: 'saved', id });
|
||||
});
|
||||
}
|
||||
|
||||
function persistServer(doc: DocumentState) {
|
||||
if (!canEdit.value) return;
|
||||
const fp = contentFingerprint(doc);
|
||||
if (fp === savedContent && pendingJson === null) return; // cursor move only
|
||||
pendingJson = JSON.stringify(doc);
|
||||
pendingFp = fp;
|
||||
clearTimeout(uploadTimer);
|
||||
uploadTimer = setTimeout(() => void upload(), UPLOAD_DELAY);
|
||||
}
|
||||
|
||||
/** Send the pending change of the open server document (one at a time). */
|
||||
async function upload(): Promise<void> {
|
||||
clearTimeout(uploadTimer);
|
||||
uploadTimer = undefined;
|
||||
if (uploading) {
|
||||
await uploading;
|
||||
if (pendingJson !== null) return upload();
|
||||
return;
|
||||
}
|
||||
const c = state.current;
|
||||
const meta = state.server;
|
||||
if (!c || c.source !== 'server' || !meta || pendingJson === null) return;
|
||||
const json = pendingJson;
|
||||
const fp = pendingFp;
|
||||
pendingJson = null;
|
||||
state.syncState = 'saving';
|
||||
uploading = (async () => {
|
||||
try {
|
||||
const r = await api<{ document: ServerDocMeta }>('PATCH', `/documents/${c.id}`, {
|
||||
body: { content: json, base_version: meta.version },
|
||||
shareToken: shareToken(c.id),
|
||||
});
|
||||
if (isCurrent(c)) {
|
||||
state.server = { ...meta, ...r.document, role: meta.role };
|
||||
savedContent = fp;
|
||||
}
|
||||
state.syncState = 'idle';
|
||||
} catch (err) {
|
||||
await uploadFailed(c, err, json, fp);
|
||||
} finally {
|
||||
uploading = null;
|
||||
}
|
||||
})();
|
||||
await uploading;
|
||||
if (pendingJson !== null && isCurrent(c)) return upload();
|
||||
}
|
||||
|
||||
async function uploadFailed(c: DocRef, err: unknown, json: string, fp: string) {
|
||||
if (!(err instanceof ApiError) || err.status === 0 || err.status >= 500) {
|
||||
// offline / server trouble: keep it and try again
|
||||
state.syncState = 'offline';
|
||||
if (pendingJson === null) {
|
||||
pendingJson = json;
|
||||
pendingFp = fp;
|
||||
}
|
||||
clearTimeout(uploadTimer);
|
||||
uploadTimer = setTimeout(() => void upload(), 5000);
|
||||
return;
|
||||
}
|
||||
state.syncState = 'idle';
|
||||
// the server's version wins; edits made meanwhile would be based on the
|
||||
// rejected state, so they go too
|
||||
pendingJson = null;
|
||||
clearTimeout(uploadTimer);
|
||||
if (err.status === 423) {
|
||||
holdsLock = false;
|
||||
state.lockedBy = (err.body.holder_name as string) || 'Jemand anderes';
|
||||
toasts.error(`${state.lockedBy} bearbeitet dieses Dokument inzwischen – deine letzte Änderung wurde nicht gespeichert.`);
|
||||
} else if (err.status === 409) {
|
||||
toasts.error('Das Dokument wurde inzwischen anderswo geändert – deine letzte Änderung wurde nicht gespeichert.');
|
||||
} else {
|
||||
toasts.error(`Speichern fehlgeschlagen: ${err.message}`);
|
||||
}
|
||||
if (isCurrent(c)) await reloadServer(c.id, true);
|
||||
startServerTimers();
|
||||
}
|
||||
|
||||
/** Before leaving the open server document: send what's pending. */
|
||||
async function flushServer() {
|
||||
store.flushSave();
|
||||
if (pendingJson !== null || uploading) await upload();
|
||||
}
|
||||
|
||||
// ---- edit lock + polling ---------------------------------------------
|
||||
function stopServerTimers() {
|
||||
clearInterval(heartbeatTimer);
|
||||
clearInterval(pollTimer);
|
||||
heartbeatTimer = pollTimer = undefined;
|
||||
}
|
||||
|
||||
function startServerTimers() {
|
||||
stopServerTimers();
|
||||
if (state.current?.source !== 'server') return;
|
||||
if (holdsLock) heartbeatTimer = setInterval(() => void heartbeat(), HEARTBEAT);
|
||||
else pollTimer = setInterval(() => void poll(), POLL);
|
||||
}
|
||||
|
||||
/** Try to take the edit lock of the open server document. */
|
||||
async function takeLock(id: string): Promise<boolean> {
|
||||
if (currentRole.value === 'viewer') return false;
|
||||
try {
|
||||
const r = await api<{ lock: { mine: boolean; holder_name: string | null } }>('POST', `/documents/${id}/lock`, {
|
||||
shareToken: shareToken(id),
|
||||
});
|
||||
holdsLock = r.lock.mine;
|
||||
state.lockedBy = r.lock.mine ? null : r.lock.holder_name || 'Jemand anderes';
|
||||
} catch {
|
||||
holdsLock = false;
|
||||
}
|
||||
applyReadOnly();
|
||||
return holdsLock;
|
||||
}
|
||||
|
||||
async function heartbeat() {
|
||||
const c = state.current;
|
||||
if (c?.source !== 'server') return;
|
||||
const had = holdsLock;
|
||||
await takeLock(c.id);
|
||||
if (had && !holdsLock) {
|
||||
toasts.error(`${state.lockedBy ?? 'Jemand anderes'} bearbeitet dieses Dokument jetzt.`);
|
||||
startServerTimers();
|
||||
}
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
const c = state.current;
|
||||
if (c?.source !== 'server' || document.visibilityState === 'hidden') return;
|
||||
if (currentRole.value !== 'viewer' && (await takeLock(c.id))) {
|
||||
await reloadServer(c.id, true);
|
||||
toasts.info('Du kannst das Dokument jetzt bearbeiten.');
|
||||
startServerTimers();
|
||||
return;
|
||||
}
|
||||
await reloadServer(c.id, true);
|
||||
}
|
||||
|
||||
function releaseLock(id: string) {
|
||||
if (!holdsLock) return;
|
||||
holdsLock = false;
|
||||
void api('DELETE', `/documents/${id}/lock`, { shareToken: shareToken(id), keepalive: true }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Fetch the server document again; with `keepView` only when it changed. */
|
||||
async function reloadServer(id: string, keepView: boolean) {
|
||||
const known = keepView ? state.server?.version : undefined;
|
||||
try {
|
||||
const q = known ? `?known_version=${known}` : '';
|
||||
const r = await api<{ document: ServerDoc; unchanged?: boolean }>('GET', `/documents/${id}${q}`, {
|
||||
shareToken: shareToken(id),
|
||||
});
|
||||
if (!isCurrent({ source: 'server', id })) return;
|
||||
const { content } = r.document;
|
||||
state.server = metaOf(r.document);
|
||||
if (!r.unchanged && content !== undefined) {
|
||||
show({ source: 'server', id }, validateDocument(JSON.parse(content)), keepView);
|
||||
}
|
||||
updateWindowTitle();
|
||||
applyReadOnly();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
toasts.error('Kein Zugriff mehr auf dieses Dokument.');
|
||||
await openFallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- leaving / opening -----------------------------------------------
|
||||
async function leaveCurrent() {
|
||||
const c = state.current;
|
||||
if (!c) return;
|
||||
if (c.source === 'server') {
|
||||
await flushServer();
|
||||
stopServerTimers();
|
||||
releaseLock(c.id);
|
||||
state.lockedBy = null;
|
||||
state.server = null;
|
||||
} else {
|
||||
store.flushSave();
|
||||
await writeQueue;
|
||||
}
|
||||
}
|
||||
|
||||
async function openLocal(id: string, opts: { push?: boolean } = {}): Promise<boolean> {
|
||||
if (!lib) return false;
|
||||
if (isCurrent({ source: 'local', id })) return true;
|
||||
const [meta, json] = await Promise.all([lib.getMeta(id), lib.getContent(id)]);
|
||||
if (!meta || json === undefined) return false;
|
||||
let next: DocumentState;
|
||||
try {
|
||||
next = validateDocument(JSON.parse(json));
|
||||
} catch (err) {
|
||||
toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
await leaveCurrent();
|
||||
show({ source: 'local', id }, next);
|
||||
knownUpdatedAt = meta.updatedAt;
|
||||
applyReadOnly();
|
||||
setUrl({ source: 'local', id }, opts.push ?? true);
|
||||
post({ type: 'hello', id });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openServer(id: string, opts: { push?: boolean } = {}): Promise<boolean> {
|
||||
if (isCurrent({ source: 'server', id })) return true;
|
||||
let r: { document: ServerDoc };
|
||||
try {
|
||||
r = await api<{ document: ServerDoc }>('GET', `/documents/${id}`, { shareToken: shareToken(id) });
|
||||
} catch (err) {
|
||||
toasts.error(
|
||||
err instanceof ApiError && err.status === 404
|
||||
? 'Dieses Dokument gibt es nicht, oder du hast keinen Zugriff darauf.'
|
||||
: `Dokument konnte nicht geladen werden: ${(err as Error).message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const { content } = r.document;
|
||||
const meta = metaOf(r.document);
|
||||
let next: DocumentState;
|
||||
try {
|
||||
next = validateDocument(JSON.parse(content ?? ''));
|
||||
} catch (err) {
|
||||
toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
await leaveCurrent();
|
||||
state.server = meta;
|
||||
state.lockedBy = null;
|
||||
holdsLock = false;
|
||||
show({ source: 'server', id }, next);
|
||||
// remember the title for link-only documents
|
||||
const link = state.linkDocs.find((l) => l.id === id);
|
||||
if (link && (link.title !== meta.title || link.role !== meta.role)) {
|
||||
Object.assign(link, { title: meta.title, role: meta.role });
|
||||
writeLinks(state.linkDocs);
|
||||
}
|
||||
applyReadOnly();
|
||||
if (meta.role !== 'viewer') await takeLock(id);
|
||||
startServerTimers();
|
||||
setUrl({ source: 'server', id }, opts.push ?? true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function open(ref: DocRef, opts: { push?: boolean } = {}) {
|
||||
return ref.source === 'server' ? openServer(ref.id, opts) : openLocal(ref.id, opts);
|
||||
}
|
||||
|
||||
// ---- creating --------------------------------------------------------
|
||||
/** Add a document and open it: on the server when signed in, else locally. */
|
||||
async function create(doc: DocumentState, title: string): Promise<DocRef | null> {
|
||||
const t = title.trim() || DEFAULT_TITLE;
|
||||
if (session.signedIn.value) {
|
||||
try {
|
||||
const r = await api<{ document: ServerDocMeta }>('POST', '/documents', {
|
||||
body: { title: uniqueTitle(t, state.serverDocs.map((d) => d.title)), content: JSON.stringify(doc) },
|
||||
});
|
||||
await refreshServerList();
|
||||
post({ type: 'list' });
|
||||
await openServer(r.document.id);
|
||||
return { source: 'server', id: r.document.id };
|
||||
} catch (err) {
|
||||
toasts.error(`Anlegen auf dem Server fehlgeschlagen: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!lib) {
|
||||
store.replaceDocument(doc); // no storage at all: at least show it
|
||||
return null;
|
||||
}
|
||||
const id = newDocId();
|
||||
const now = Date.now();
|
||||
await enqueue(() =>
|
||||
lib!.put({ id, title: uniqueTitle(t, state.docs.map((d) => d.title)), createdAt: now, updatedAt: now }, JSON.stringify(doc)),
|
||||
);
|
||||
await refreshList();
|
||||
post({ type: 'list' });
|
||||
await openLocal(id);
|
||||
return { source: 'local', id };
|
||||
}
|
||||
|
||||
const createBlank = () => create(createBlankDocument(), DEFAULT_TITLE);
|
||||
const createSample = () => create(createSeedDocument(), 'Muster · Umsatzanalyse');
|
||||
|
||||
// ---- managing ----------------------------------------------------------
|
||||
async function rename(ref: DocRef, title: string) {
|
||||
const t = title.trim();
|
||||
if (!t) return;
|
||||
if (ref.source === 'server') {
|
||||
try {
|
||||
const r = await api<{ document: ServerDocMeta }>('PATCH', `/documents/${ref.id}`, {
|
||||
body: { title: t },
|
||||
shareToken: shareToken(ref.id),
|
||||
});
|
||||
if (isCurrent(ref) && state.server) state.server = { ...state.server, title: r.document.title };
|
||||
await refreshServerList();
|
||||
} catch (err) {
|
||||
toasts.error(`Umbenennen fehlgeschlagen: ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
if (!lib) return;
|
||||
await enqueue(() => lib!.patchMeta(ref.id, { title: t }));
|
||||
await refreshList();
|
||||
}
|
||||
if (isCurrent(ref)) updateWindowTitle();
|
||||
post({ type: 'list' });
|
||||
}
|
||||
|
||||
async function contentOf(ref: DocRef): Promise<{ title: string; json: string } | null> {
|
||||
if (isCurrent(ref)) {
|
||||
if (ref.source === 'server') await flushServer();
|
||||
else {
|
||||
store.flushSave();
|
||||
await writeQueue;
|
||||
}
|
||||
}
|
||||
if (ref.source === 'server') {
|
||||
const r = await api<{ document: ServerDoc }>('GET', `/documents/${ref.id}`, { shareToken: shareToken(ref.id) });
|
||||
return r.document.content === undefined ? null : { title: r.document.title, json: r.document.content };
|
||||
}
|
||||
if (!lib) return null;
|
||||
const [meta, json] = await Promise.all([lib.getMeta(ref.id), lib.getContent(ref.id)]);
|
||||
return meta && json !== undefined ? { title: meta.title, json } : null;
|
||||
}
|
||||
|
||||
/** A copy next to the original (server copies go to the own account). */
|
||||
async function duplicate(ref: DocRef) {
|
||||
try {
|
||||
const src = await contentOf(ref);
|
||||
if (!src) return;
|
||||
const title = `${src.title} (Kopie)`;
|
||||
if (ref.source === 'server') {
|
||||
await api('POST', '/documents', {
|
||||
body: { title: uniqueTitle(title, state.serverDocs.map((d) => d.title)), content: src.json },
|
||||
});
|
||||
await refreshServerList();
|
||||
} else if (lib) {
|
||||
const now = Date.now();
|
||||
await enqueue(() =>
|
||||
lib!.put(
|
||||
{ id: newDocId(), title: uniqueTitle(title, state.docs.map((d) => d.title)), createdAt: now, updatedAt: now },
|
||||
src.json,
|
||||
),
|
||||
);
|
||||
await refreshList();
|
||||
}
|
||||
post({ type: 'list' });
|
||||
} catch (err) {
|
||||
toasts.error(`Duplizieren fehlgeschlagen: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete (own / local) or leave (shared with me / link). */
|
||||
async function remove(ref: DocRef) {
|
||||
if (ref.source === 'server') {
|
||||
const wasCurrent = isCurrent(ref);
|
||||
if (wasCurrent) await leaveCurrent();
|
||||
if (session.signedIn.value) {
|
||||
try {
|
||||
await api('DELETE', `/documents/${ref.id}`);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 404)) {
|
||||
toasts.error(`Löschen fehlgeschlagen: ${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.linkDocs = state.linkDocs.filter((l) => l.id !== ref.id);
|
||||
writeLinks(state.linkDocs);
|
||||
await refreshServerList();
|
||||
post({ type: 'list' });
|
||||
if (wasCurrent) {
|
||||
state.current = null;
|
||||
await openFallback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!lib) return;
|
||||
await enqueue(() => lib!.remove(ref.id));
|
||||
await refreshList();
|
||||
post({ type: 'deleted', id: ref.id });
|
||||
if (isCurrent(ref)) {
|
||||
state.current = null;
|
||||
await openFallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move local documents into the account (server). Each one is removed from
|
||||
* this browser once it's safely on the server; an open one is reopened
|
||||
* from there. Returns the server id of each uploaded document.
|
||||
*/
|
||||
async function upload_(localIds: string[]): Promise<Map<string, string>> {
|
||||
const done = new Map<string, string>();
|
||||
if (!lib || !session.signedIn.value) return done;
|
||||
for (const id of localIds) {
|
||||
try {
|
||||
const src = await contentOf({ source: 'local', id });
|
||||
if (!src) continue;
|
||||
const r = await api<{ document: ServerDocMeta }>('POST', '/documents', {
|
||||
body: { title: src.title, content: src.json },
|
||||
});
|
||||
done.set(id, r.document.id);
|
||||
const wasCurrent = isCurrent({ source: 'local', id });
|
||||
if (wasCurrent) await openServer(r.document.id, { push: false });
|
||||
await enqueue(() => lib!.remove(id));
|
||||
post({ type: 'deleted', id });
|
||||
} catch (err) {
|
||||
toasts.error(`Hochladen fehlgeschlagen: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
await Promise.all([refreshList(), refreshServerList()]);
|
||||
post({ type: 'list' });
|
||||
return done;
|
||||
}
|
||||
|
||||
/** Most recently changed document, or a new blank one. */
|
||||
async function openFallback() {
|
||||
state.current = null;
|
||||
const server = state.serverDocs[0];
|
||||
const local = state.docs[0];
|
||||
const serverNewer = server && (!local || Date.parse(server.updated_at) > local.updatedAt);
|
||||
if (serverNewer && (await openServer(server.id, { push: false }))) return;
|
||||
if (local && (await openLocal(local.id, { push: false }))) return;
|
||||
await createBlank();
|
||||
}
|
||||
|
||||
// ---- share links -------------------------------------------------------
|
||||
/** Redeem `#k=<token>` for document `id` (from the URL). */
|
||||
async function joinByLink(id: string, token: string) {
|
||||
try {
|
||||
const r = await api<{ document: ServerDocMeta }>('POST', `/documents/${id}/join`, { body: { token } });
|
||||
if (session.signedIn.value) {
|
||||
await refreshServerList(); // it's in the account now
|
||||
} else {
|
||||
state.linkDocs = [
|
||||
{ id, token, title: r.document.title, role: r.document.role },
|
||||
...state.linkDocs.filter((l) => l.id !== id),
|
||||
];
|
||||
writeLinks(state.linkDocs);
|
||||
}
|
||||
} catch {
|
||||
toasts.error('Dieser Link ist nicht (mehr) gültig.');
|
||||
}
|
||||
}
|
||||
|
||||
/** After signing in: documents opened via link become part of the account. */
|
||||
async function adoptLinkDocs() {
|
||||
if (!session.signedIn.value || !state.linkDocs.length) return;
|
||||
for (const l of state.linkDocs) {
|
||||
await api('POST', `/documents/${l.id}/join`, { body: { token: l.token } }).catch(() => {});
|
||||
}
|
||||
state.linkDocs = [];
|
||||
writeLinks([]);
|
||||
}
|
||||
|
||||
// ---- other tabs --------------------------------------------------------
|
||||
async function onMessage(m: Msg) {
|
||||
if (m.type === 'list') {
|
||||
await Promise.all([refreshList(), refreshServerList()]);
|
||||
state.linkDocs = readLinks();
|
||||
updateWindowTitle();
|
||||
return;
|
||||
}
|
||||
const cur = state.current?.source === 'local' ? state.current.id : null;
|
||||
if (m.type === 'saved') {
|
||||
await refreshList();
|
||||
// our own unsaved edit wins (it's written right after); otherwise
|
||||
// follow -- a pending save that's only a cursor move doesn't count
|
||||
const localEdit = store.hasPendingSave() && contentFingerprint(store.doc) !== savedContent;
|
||||
if (m.id === cur && !localEdit && lib) {
|
||||
const [meta, json] = await Promise.all([lib.getMeta(m.id), lib.getContent(m.id)]);
|
||||
if (meta && json !== undefined && isCurrent({ source: 'local', id: m.id })) {
|
||||
show({ source: 'local', id: m.id }, validateDocument(JSON.parse(json)), true);
|
||||
knownUpdatedAt = meta.updatedAt;
|
||||
}
|
||||
}
|
||||
} else if (m.type === 'deleted') {
|
||||
await refreshList();
|
||||
if (m.id === cur) {
|
||||
toasts.info('Dieses Dokument ist nicht mehr in diesem Browser (gelöscht oder hochgeladen).');
|
||||
state.current = null;
|
||||
await refreshServerList();
|
||||
await openFallback();
|
||||
}
|
||||
} else if (m.id === cur) {
|
||||
// hello / here: the same local document is open in another tab
|
||||
if (m.type === 'hello') post({ type: 'here', id: m.id });
|
||||
if (!warnedShared.has(m.id)) {
|
||||
warnedShared.add(m.id);
|
||||
toasts.info(
|
||||
'Dieses Dokument ist auch in einem anderen Tab geöffnet. Gleichzeitige Änderungen können sich überschreiben.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- start -------------------------------------------------------------
|
||||
async function migrateLegacy() {
|
||||
for (const key of LEGACY_KEYS) {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = localStorage.getItem(key);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const doc = validateDocument(JSON.parse(raw));
|
||||
const now = Date.now();
|
||||
await lib!.put({ id: newDocId(), title: 'Mein Dokument', createdAt: now, updatedAt: now }, JSON.stringify(doc));
|
||||
} catch {
|
||||
continue; // corrupt -- leave it where it is
|
||||
}
|
||||
for (const k of LEGACY_KEYS) localStorage.removeItem(k);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the URL asks for: `/d/<id>[#k=<token>]` or `/?doc=<id>`. */
|
||||
function refFromUrl(): { ref: DocRef | null; token: string | null } {
|
||||
const m = location.pathname.match(/^\/d\/([0-9a-f-]{36})\/?$/i);
|
||||
if (m) {
|
||||
const token = new URLSearchParams(location.hash.slice(1)).get('k');
|
||||
return { ref: { source: 'server', id: m[1] }, token };
|
||||
}
|
||||
const id = new URL(location.href).searchParams.get('doc');
|
||||
return { ref: id ? { source: 'local', id } : null, token: null };
|
||||
}
|
||||
|
||||
async function init() {
|
||||
setReadOnlyNotifier((why) => toasts.info(why));
|
||||
try {
|
||||
lib = await DocumentLibrary.open();
|
||||
await migrateLegacy();
|
||||
await refreshList();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
lib = null;
|
||||
state.available = false;
|
||||
if (!session.signedIn.value) {
|
||||
toasts.error('Dokumente können in diesem Browser nicht gespeichert werden (IndexedDB nicht verfügbar).');
|
||||
}
|
||||
}
|
||||
await adoptLinkDocs();
|
||||
await refreshServerList();
|
||||
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
channel = new BroadcastChannel(CHANNEL);
|
||||
channel.onmessage = (e: MessageEvent<Msg>) => void onMessage(e.data);
|
||||
}
|
||||
setPersistHandler(persist);
|
||||
|
||||
const { ref, token } = refFromUrl();
|
||||
if (ref?.source === 'server' && token) await joinByLink(ref.id, token);
|
||||
if (ref && !(await open(ref, { push: false })) && ref.source === 'local') {
|
||||
toasts.error('Das angeforderte Dokument gibt es in diesem Browser nicht.');
|
||||
}
|
||||
if (!state.current) await openFallback();
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
const { ref: r } = refFromUrl();
|
||||
if (r && !isCurrent(r)) void open(r, { push: false });
|
||||
});
|
||||
// don't lose the last moments of typing when the tab goes away
|
||||
window.addEventListener('pagehide', () => {
|
||||
store.flushSave();
|
||||
if (state.current?.source === 'server') {
|
||||
if (pendingJson !== null) void upload();
|
||||
releaseLock(state.current.id);
|
||||
}
|
||||
});
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (pendingJson !== null || uploading) e.preventDefault(); // unsaved server changes
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') store.flushSave();
|
||||
});
|
||||
state.ready = true;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
currentTitle,
|
||||
currentRole,
|
||||
canEdit,
|
||||
init,
|
||||
open,
|
||||
create,
|
||||
createBlank,
|
||||
createSample,
|
||||
rename,
|
||||
duplicate,
|
||||
remove,
|
||||
upload: upload_,
|
||||
refreshServerList,
|
||||
};
|
||||
}
|
||||
|
||||
let instance: ReturnType<typeof createLibrary> | null = null;
|
||||
|
||||
export function useLibrary() {
|
||||
if (!instance) instance = createLibrary();
|
||||
return instance;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Who is signed in. An account is optional: without one, documents stay in
|
||||
* this browser (IndexedDB); with one, they live on the server and can be
|
||||
* shared. Sign-in is by magic link or the 6-digit code from the same mail.
|
||||
*/
|
||||
import { computed, reactive } from 'vue';
|
||||
import { api, bootData, type User } from '../io/api';
|
||||
|
||||
const state = reactive({ user: bootData().user as User | null });
|
||||
|
||||
export function useSession() {
|
||||
const signedIn = computed(() => !!state.user);
|
||||
|
||||
/** Mail a sign-in link + code. `returnTo` = where the link leads back to. */
|
||||
async function requestLink(email: string, returnTo = location.pathname + location.search) {
|
||||
await api('POST', '/auth/magic_link', { body: { email, return_to: returnTo } });
|
||||
}
|
||||
|
||||
/** Sign in with the code. The page reloads, so everything starts signed in. */
|
||||
async function verifyCode(email: string, code: string) {
|
||||
await api<{ redirect: string }>('POST', '/auth/code', { body: { email, code } });
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api('DELETE', '/auth/session');
|
||||
location.assign('/');
|
||||
}
|
||||
|
||||
async function setDisplayName(display_name: string) {
|
||||
const r = await api<{ user: User }>('PATCH', '/me', { body: { display_name } });
|
||||
state.user = r.user;
|
||||
}
|
||||
|
||||
return { state, signedIn, requestLink, verifyCode, logout, setDisplayName };
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Persisted / JSON-exported document shape. **Version 2** — Numbers-style
|
||||
* canvas model: a sheet is a free surface holding table, chart and text *objects*.
|
||||
*
|
||||
* (Version 1 was one grid per sheet; `io/json.ts` migrates v1 -> v2 on import.)
|
||||
*/
|
||||
|
||||
import type { NumberLocale } from './format';
|
||||
import type { CellValue } from '../engine';
|
||||
|
||||
export interface CellCoord {
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
export type HAlign = 'left' | 'center' | 'right';
|
||||
export type VAlign = 'top' | 'middle' | 'bottom';
|
||||
|
||||
/** Per-cell number-display rule, combined with the effective locale. */
|
||||
export type CellNumFmt =
|
||||
| 'auto'
|
||||
| 'plain'
|
||||
| 'int' // 0 decimals, grouped
|
||||
| 'dec1' // 1 decimal
|
||||
| 'dec2' // 2 decimals
|
||||
| 'pct0' // percent, 0 decimals (value 0.5 -> "50 %")
|
||||
| 'pct2' // percent, 2 decimals
|
||||
| 'date' // e.g. 02.09.2026 -- value is a date serial, see engine/dates.ts
|
||||
| 'dateiso' // e.g. 2026-09-02 -- ISO 8601, same regardless of locale
|
||||
| 'time' // e.g. 14:30 -- time of day, wraps at 24 h
|
||||
| 'datetime' // date + time
|
||||
| 'duration'; // e.g. 37:30 -- elapsed hours, never wraps at 24 h
|
||||
|
||||
/** Explicit formatting for a single cell (all fields optional = inherit). */
|
||||
export interface CellFormat {
|
||||
align?: HAlign;
|
||||
valign?: VAlign;
|
||||
numFmt?: CellNumFmt;
|
||||
/** text colour (#rgb/#rrggbb/#rrggbbaa) */
|
||||
color?: string;
|
||||
/** fill colour */
|
||||
bg?: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
}
|
||||
|
||||
/** How a table object is sized on the canvas. */
|
||||
export interface TableView {
|
||||
mode: 'auto' | 'fixed';
|
||||
width: number;
|
||||
height: number;
|
||||
frozenRows: number;
|
||||
frozenCols: number;
|
||||
}
|
||||
|
||||
export function defaultTableView(): TableView {
|
||||
return { mode: 'auto', width: 640, height: 320, frozenRows: 0, frozenCols: 0 };
|
||||
}
|
||||
|
||||
export const DEFAULT_ROW_H = 26;
|
||||
|
||||
/** A table object placed on a sheet canvas. */
|
||||
export interface TableObject {
|
||||
kind: 'table';
|
||||
id: string;
|
||||
/** document-unique name; the qualifier used in formulas (`Umsatz!B2`) */
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
rows: number;
|
||||
cols: number;
|
||||
/** column index -> pixel width (only non-default widths are stored) */
|
||||
colWidths: Record<number, number>;
|
||||
/** default row height (px); unset = DEFAULT_ROW_H */
|
||||
rowHeight?: number;
|
||||
/** row index -> pixel height override */
|
||||
rowHeights?: Record<number, number>;
|
||||
/** "col,row" -> raw user input (formula string or literal). Empty cells absent. */
|
||||
cells: Record<string, string>;
|
||||
/** "col,row" -> explicit cell formatting */
|
||||
formats?: Record<string, CellFormat>;
|
||||
/** sizing + frozen rows/cols */
|
||||
view: TableView;
|
||||
/** number-format locale override; falls back to the sheet, then the document */
|
||||
locale?: NumberLocale;
|
||||
/** object background colour override (grid surface) */
|
||||
bg?: string;
|
||||
/** object default text colour override */
|
||||
fg?: string;
|
||||
/** when true, this table's title bar and row/column headers are only
|
||||
* shown while it's the selected canvas object -- otherwise (including
|
||||
* right after the document loads, before anything is selected) they're
|
||||
* hidden entirely, not just visually: the table's footprint shrinks to
|
||||
* just its cell grid. A presentation/"clean look" option; unset/false =
|
||||
* always shown, today's behaviour. */
|
||||
hideChromeUnfocused?: boolean;
|
||||
}
|
||||
|
||||
/** A chart object placed on a sheet canvas. Freely positioned and resized. */
|
||||
export interface ChartObject {
|
||||
kind: 'chart';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
view: 'bar' | 'line';
|
||||
sourceTableId: string | null;
|
||||
locale?: NumberLocale;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
/** A free-floating formatted text box, a companion to tables and charts. */
|
||||
export interface TextObject {
|
||||
kind: 'text';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
align: HAlign;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
color?: string;
|
||||
bg?: string;
|
||||
}
|
||||
|
||||
/** A clickable canvas object that runs an assigned macro (see `Macro`
|
||||
* below) when clicked -- the second of the two trigger UIs the macro
|
||||
* feature offers, alongside the "▶ Ausführen" button in the Makros panel.
|
||||
* Freely positioned/resized like a chart or text box, no intrinsic size. */
|
||||
export interface ButtonObject {
|
||||
kind: 'button';
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** button caption, independent of `name` (the canvas-object label shown
|
||||
* in its chrome/rename-on-dblclick) */
|
||||
label: string;
|
||||
/** which macro a click runs; `null` = unassigned (shows a hint instead
|
||||
* of running anything). A dangling id (its macro was deleted, or an
|
||||
* imported document's macro wasn't carried along) is treated the same
|
||||
* as `null` -- the same lenient convention as a chart's dangling
|
||||
* `sourceTableId`. */
|
||||
macroId: string | null;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
export type SheetObject = TableObject | ChartObject | TextObject | ButtonObject;
|
||||
|
||||
export interface Sheet {
|
||||
id: string;
|
||||
name: string;
|
||||
/** array order == z-order; the last element renders in front */
|
||||
objects: SheetObject[];
|
||||
locale?: NumberLocale;
|
||||
/** canvas backdrop colour; unset = themed default */
|
||||
background?: string;
|
||||
/** default text colour for this sheet's grids/labels; unset = themed default */
|
||||
foreground?: string;
|
||||
/** show the canvas dot-grid; unset / true = shown */
|
||||
showGrid?: boolean;
|
||||
/** grid spacing in px -- also the snap increment for moving/resizing
|
||||
* canvas objects on this sheet; unset = DEFAULT_GRID_SIZE */
|
||||
gridSize?: number;
|
||||
/** per-sheet canvas zoom (0.4 – 2); unset = 1 */
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_GRID_SIZE = 24;
|
||||
export const MIN_GRID_SIZE = 4;
|
||||
export const MAX_GRID_SIZE = 200;
|
||||
|
||||
export interface SelectionRange {
|
||||
anchor: CellCoord;
|
||||
focus: CellCoord;
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
tableId: string;
|
||||
/** primary rectangle; `focus` is the active cell (formula bar / keyboard) */
|
||||
anchor: CellCoord;
|
||||
focus: CellCoord;
|
||||
/** additional rectangles for a discontiguous multi-selection */
|
||||
ranges?: SelectionRange[];
|
||||
}
|
||||
|
||||
/** A document-wide, user-defined formula function, callable from any cell
|
||||
* like a built-in (e.g. `=MARGE(Umsatz!B2; Umsatz!C2)`). Created, edited and
|
||||
* removed exclusively through the "𝑓 Funktionen" UI -- see
|
||||
* src/engine/userFunctions.ts for how a call is expanded into a cell's AST. */
|
||||
export interface CustomFunction {
|
||||
id: string;
|
||||
/** document-unique, compared case-insensitively; never a built-in name. */
|
||||
name: string;
|
||||
/** parameter names, in call order; may be empty (a plain named formula). */
|
||||
params: string[];
|
||||
/** raw formula text, no leading "=" -- a single expression for
|
||||
* `kind: 'expression'`, a FOR/DO/RETURN statement sequence for
|
||||
* `kind: 'block'`. */
|
||||
body: string;
|
||||
/** 'expression' (default, single-formula body) or 'block' (FOR-loop
|
||||
* capable, "Mit Schleife" in the UI). Missing on any function saved
|
||||
* before this existed -- normalised to 'expression' on load. */
|
||||
kind: 'expression' | 'block';
|
||||
}
|
||||
|
||||
/** A document-wide name that stands for a fixed cell/range on a specific
|
||||
* table (`Umsatz_Q1` instead of `Umsatz!B2:B13`), usable unqualified in any
|
||||
* ordinary top-level formula (not inside a custom function's own body --
|
||||
* see engine/parser.ts's `namedRefFallback`). Created, edited and removed
|
||||
* exclusively through the "🏷 Namen" UI. Unlike a `CustomFunction`, there is
|
||||
* no expression to evaluate, just fixed coordinates -- so no cycle
|
||||
* detection is needed. `tableId` (not the table's *name*) so a named range
|
||||
* survives its target table being renamed -- see
|
||||
* engine/workbook.ts's `resolveNamedRange`. */
|
||||
export interface NamedRange {
|
||||
id: string;
|
||||
/** document-unique, compared case-insensitively; never a built-in/custom
|
||||
* function name, never shaped like a cell reference (A1, $B2, ...). */
|
||||
name: string;
|
||||
tableId: string;
|
||||
start: CellCoord;
|
||||
end: CellCoord;
|
||||
}
|
||||
|
||||
/** A document-wide, explicitly-triggered script -- NOT callable from any
|
||||
* cell formula (deliberately a separate type from `CustomFunction`, not a
|
||||
* third `kind`, so that's impossible by construction, not just
|
||||
* convention). Can freely read AND WRITE cells, rows/columns and tables/
|
||||
* sheets across the whole document (`engine/types.ts`'s `MacroWriteHost`
|
||||
* vocabulary: `SETVALUE`/`INSERTROW`/`ADDTABLE`/`ADDSHEET`/...), unlike an
|
||||
* ordinary formula -- safe only because it runs OUTSIDE the passive
|
||||
* recalculation graph, triggered by a click (the "▶ Ausführen" button in
|
||||
* the Makros panel, or a canvas `ButtonObject`), never automatically. See
|
||||
* `document.ts`'s `runMacro`/`validateMacro` and README's "Makros"
|
||||
* section. Takes no parameters (a click carries no arguments) and can
|
||||
* never call another macro (no such action exists) -- both deliberate,
|
||||
* documented v1 scope cuts.
|
||||
*
|
||||
* Also able to reach the network (`GET`/`POST`, `engine/types.ts`'s
|
||||
* `MacroNetworkHost`) -- a second trust dimension beyond read/write scope,
|
||||
* gated separately via `approvedHosts` below. */
|
||||
export interface Macro {
|
||||
id: string;
|
||||
/** document-unique, compared case-insensitively; never a built-in/custom
|
||||
* function/action name. */
|
||||
name: string;
|
||||
/** raw statement-sequence text (assignments, FOR/IF/RETURN, and
|
||||
* write-action calls), no leading "=" -- parsed via
|
||||
* `engine/parser.ts`'s `parseMacroBlock`. Every cell/range reference
|
||||
* inside must be qualified with a table name (`Umsatz::A1`, never bare
|
||||
* `A1`) -- a macro has no "home table", see `validateMacro`. */
|
||||
body: string;
|
||||
/** Hostnames (`host` from `new URL(...)`, i.e. including a non-default
|
||||
* port) this macro has already been confirmed to contact via `GET`/
|
||||
* `POST`, persisted so a human only approves each macro×host pair once,
|
||||
* ever -- not once per session, not once per document (a different
|
||||
* macro contacting an already-approved host still prompts separately;
|
||||
* see `document.ts#runMacro`'s doc comment for the reasoning). Absent/
|
||||
* empty for a macro that has never made a network call. */
|
||||
approvedHosts?: string[];
|
||||
}
|
||||
|
||||
/** One entry in the formula-autocomplete dropdown (see document.ts's
|
||||
* `functionSuggestions()`). */
|
||||
export interface FnSuggestion {
|
||||
name: string;
|
||||
/** one-line signature hint, e.g. "SUM(Zahl1; Zahl2; …)" */
|
||||
hint: string;
|
||||
kind: 'builtin' | 'custom';
|
||||
}
|
||||
|
||||
/** One entry in the Name Box's autocomplete dropdown (see document.ts's
|
||||
* `nameBoxSuggestions()`) -- a named range, table, or sheet the typed
|
||||
* prefix matches. */
|
||||
export interface NameSuggestion {
|
||||
name: string;
|
||||
/** one-line description, e.g. the range a named range points at, or
|
||||
* which sheet a table lives on. */
|
||||
hint: string;
|
||||
kind: 'namedrange' | 'table' | 'sheet';
|
||||
}
|
||||
|
||||
/** One node of the interactive dependency-graph view (document.ts's
|
||||
* `cellGraphNeighbors()`/`cellGraphSelf()`) -- a precedent/dependent cell
|
||||
* (or the centered cell itself), enriched with what the UI needs to
|
||||
* render and label it without a second lookup. */
|
||||
export interface CellGraphNode {
|
||||
tableId: string;
|
||||
tableName: string;
|
||||
/** the sheet the table lives on -- shown only when navigating makes the
|
||||
* centered cell's own sheet ambiguous otherwise. */
|
||||
sheetName: string;
|
||||
col: number;
|
||||
row: number;
|
||||
/** A1 label within its table, e.g. "B5" (never table/sheet-qualified --
|
||||
* the UI adds that itself from `tableName`/`sheetName` as needed). */
|
||||
label: string;
|
||||
/** raw input (formula text or literal), for a tooltip/detail view. */
|
||||
raw: string;
|
||||
value: CellValue;
|
||||
/** this cell's *own* value is `#CIRC` -- it sits on a circular reference. */
|
||||
isCircular: boolean;
|
||||
}
|
||||
|
||||
export interface DocumentState {
|
||||
version: 2;
|
||||
sheets: Sheet[];
|
||||
activeSheetId: string;
|
||||
activeObjectId: string | null;
|
||||
selection: SelectionState | null;
|
||||
locale: NumberLocale;
|
||||
csvDelimiter: ',' | ';';
|
||||
customFunctions: CustomFunction[];
|
||||
namedRanges: NamedRange[];
|
||||
macros: Macro[];
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Design tokens. Light palette on :root, dark overrides below.
|
||||
* A manual [data-theme] on <html> wins over prefers-color-scheme.
|
||||
* ------------------------------------------------------------------ */
|
||||
:root {
|
||||
--font-ui: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
|
||||
--bg: #f5f5f7;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #fbfbfd;
|
||||
--header-bg: #f0f0f3;
|
||||
--text: #1d1d1f;
|
||||
--text-dim: #6e6e73;
|
||||
--border: #d9d9de;
|
||||
--grid-line: #e6e6ea;
|
||||
--accent: #0a84ff;
|
||||
--accent-weak: rgba(10, 132, 255, 0.14);
|
||||
--accent-header: rgba(10, 132, 255, 0.22);
|
||||
--danger: #ff3b30;
|
||||
--logo-keyline: #111111;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 6px 20px rgba(0, 0, 0, 0.06);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
|
||||
/* chart series -- consistent across bar & line */
|
||||
--series-1: #0a84ff;
|
||||
--series-2: #30d158;
|
||||
--series-3: #ff9f0a;
|
||||
--series-4: #ff375f;
|
||||
--series-5: #bf5af2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--bg: #1c1c1e;
|
||||
--surface: #2c2c2e;
|
||||
--surface-2: #262628;
|
||||
--header-bg: #333336;
|
||||
--text: #f5f5f7;
|
||||
--text-dim: #a1a1a6;
|
||||
--border: #3a3a3d;
|
||||
--grid-line: #38383b;
|
||||
--accent: #0a84ff;
|
||||
--accent-weak: rgba(10, 132, 255, 0.22);
|
||||
--accent-header: rgba(10, 132, 255, 0.32);
|
||||
--logo-keyline: #f2f2f4;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--bg: #1c1c1e;
|
||||
--surface: #2c2c2e;
|
||||
--surface-2: #262628;
|
||||
--header-bg: #333336;
|
||||
--text: #f5f5f7;
|
||||
--text-dim: #a1a1a6;
|
||||
--border: #3a3a3d;
|
||||
--grid-line: #38383b;
|
||||
--accent: #0a84ff;
|
||||
--accent-weak: rgba(10, 132, 255, 0.22);
|
||||
--accent-header: rgba(10, 132, 255, 0.32);
|
||||
--logo-keyline: #f2f2f4;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden; /* the app manages its own scroll containers */
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
module ApplicationHelper
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class ApplicationJob < ActiveJob::Base
|
||||
# Automatically retry jobs that encountered a deadlock
|
||||
# retry_on ActiveRecord::Deadlocked
|
||||
|
||||
# Most jobs are safe to ignore if the underlying records are no longer available
|
||||
# discard_on ActiveJob::DeserializationError
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: ENV.fetch("NUMOLUS_MAIL_FROM", "numolus <noreply@wicom.li>")
|
||||
layout "mailer"
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class AuthMailer < ApplicationMailer
|
||||
def magic_link(link, token, code)
|
||||
@url = verify_auth_url(token:)
|
||||
@code = code
|
||||
mail to: link.email, subject: "Dein Anmeldelink für numolus"
|
||||
end
|
||||
|
||||
def invitation(link, token)
|
||||
@url = verify_auth_url(token:)
|
||||
@inviter = link.invited_by.name
|
||||
@title = link.document.title
|
||||
@role = link.grants_editor? ? "bearbeiten" : "ansehen"
|
||||
mail to: link.email, subject: "#{@inviter} hat „#{@title}“ mit dir geteilt"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :session
|
||||
delegate :user, to: :session, allow_nil: true
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
class Document < ApplicationRecord
|
||||
ROLES = %w[viewer editor owner].freeze
|
||||
LOCK_TTL = 90.seconds
|
||||
MAX_CONTENT_BYTES = 20.megabytes
|
||||
|
||||
belongs_to :owner, class_name: "User"
|
||||
belongs_to :updated_by, class_name: "User", optional: true
|
||||
has_many :document_memberships, dependent: :delete_all
|
||||
has_many :members, through: :document_memberships, source: :user
|
||||
has_many :share_links, dependent: :delete_all
|
||||
|
||||
validates :title, presence: true, length: { maximum: 200 }
|
||||
validates :content, presence: true
|
||||
validate :content_is_document_json, if: :content_changed?
|
||||
|
||||
before_validation { self.content_updated_at ||= Time.current }
|
||||
|
||||
# :owner / :editor / :viewer / nil for a signed-in user and/or a share
|
||||
# link token (a visitor without an account holds only the token). The
|
||||
# strongest of all applicable grants wins.
|
||||
def role_for(user: nil, token: nil)
|
||||
roles = []
|
||||
if user
|
||||
return :owner if owner_id == user.id
|
||||
m = document_memberships.find_by(user_id: user.id)
|
||||
roles << m.role.to_sym if m
|
||||
end
|
||||
if token.present?
|
||||
link = share_links.active.find_by(token: token)
|
||||
roles << link.role.to_sym if link
|
||||
end
|
||||
roles.max_by { |r| ROLES.index(r.to_s) }
|
||||
end
|
||||
|
||||
def self.role_at_least?(role, minimum)
|
||||
role.present? && ROLES.index(role.to_s) >= ROLES.index(minimum.to_s)
|
||||
end
|
||||
|
||||
# ---- edit lock --------------------------------------------------------
|
||||
def locked_by_other?(holder)
|
||||
lock_holder.present? && lock_holder != holder && lock_expires_at&.future?
|
||||
end
|
||||
|
||||
# Take or refresh the lock for `holder`. Returns true when `holder` has it.
|
||||
def acquire_lock!(holder, name)
|
||||
with_lock do
|
||||
return false if locked_by_other?(holder)
|
||||
update_columns(lock_holder: holder, lock_holder_name: name, lock_expires_at: LOCK_TTL.from_now)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def release_lock!(holder)
|
||||
with_lock do
|
||||
update_columns(lock_holder: nil, lock_holder_name: nil, lock_expires_at: nil) if lock_holder == holder
|
||||
end
|
||||
end
|
||||
|
||||
def lock_info(holder)
|
||||
{ locked_by_other: locked_by_other?(holder), holder_name: (lock_holder_name if locked_by_other?(holder)) }
|
||||
end
|
||||
|
||||
def meta_json(role:)
|
||||
{
|
||||
id:, title:, version:, role:,
|
||||
owner_name: owner.name,
|
||||
updated_at: content_updated_at.iso8601(3),
|
||||
updated_by_name: updated_by&.name
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def content_is_document_json
|
||||
if content.bytesize > MAX_CONTENT_BYTES
|
||||
errors.add(:content, "ist zu groß (max. #{MAX_CONTENT_BYTES / 1.megabyte} MB)")
|
||||
return
|
||||
end
|
||||
parsed = JSON.parse(content)
|
||||
errors.add(:content, "ist kein numolus-Dokument") unless parsed.is_a?(Hash) && parsed["sheets"].is_a?(Array)
|
||||
rescue JSON::ParserError
|
||||
errors.add(:content, "ist kein gültiges JSON")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
class DocumentMembership < ApplicationRecord
|
||||
belongs_to :document
|
||||
belongs_to :user
|
||||
belongs_to :share_link, optional: true
|
||||
|
||||
enum :role, { viewer: 0, editor: 1 }, validate: true
|
||||
enum :source, { invite: 0, link: 1 }, validate: true
|
||||
|
||||
# Grant access. An invitation is explicit: it sets exactly the invited role
|
||||
# and survives revoking any link. A link never weakens existing access and
|
||||
# never turns an invitation back into link-based access.
|
||||
def self.grant!(document:, user:, role:, source:, share_link: nil)
|
||||
return if document.owner_id == user.id
|
||||
m = find_or_initialize_by(document:, user:)
|
||||
if source.to_s == "invite"
|
||||
m.assign_attributes(role:, source: :invite, share_link: nil)
|
||||
elsif m.new_record?
|
||||
m.assign_attributes(role:, source: :link, share_link:)
|
||||
elsif m.link? && roles[role.to_s] > roles[m.role]
|
||||
m.assign_attributes(role:, share_link:)
|
||||
end
|
||||
m.save!
|
||||
m
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user