// State of the reader: the document, the view (in force / previews), the // language the text is read in, and the panel (which unit, which version, // which tab) — the panel lives in the URL so a link reopens it, as does the // vote unfolded under an article (`?vote=`). import { api, ApiError } from './api'; import type { GroupView } from './group'; import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types'; export type View = 'inforce' | 'wellrated' | 'all'; /** The panel: the basket of the vote, the versions of an article, a new proposal, the details of a version, the formula. */ export type Tab = 'basket' | 'proposals' | 'propose' | 'details' | 'settings'; export interface PanelState { unitId: string | null; proposalId: string | null; tab: Tab; draftId?: string | null; /** The basket, opened on one section. */ section?: string | null; prefill?: { title: string; body: string } | null; /** A new proposal that answers this objection (ADR 0010). */ objectionId?: string | null; /** The details of one's own version, its edit form open: to reword it. */ edit?: boolean; } /** The tabs as the URL names them. */ export const TABS: Record = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' }; export const TAB_NAMES: Record = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' }; /** The query of a reader's URL: the unfolded vote and the panel. */ export function panelSearch(panel: PanelState | null, vote: string | null): string { const q = new URLSearchParams(); if (vote) q.set('vote', vote); if (panel) { q.set('onglet', TAB_NAMES[panel.tab]); if (panel.unitId) q.set('article', panel.unitId); if (panel.proposalId) q.set('version', panel.proposalId); if (panel.draftId) q.set('brouillon', panel.draftId); if (panel.section) q.set('section', panel.section); if (panel.objectionId) q.set('objection', panel.objectionId); if (panel.edit) q.set('modifier', '1'); } return q.toString(); } export interface Shown { title: string; body: string; /** The text is a translation (editorial). */ translated: boolean; stale: boolean; /** Asked for a translation that does not exist yet: the source is shown. */ missing: boolean; } export function shown( v: { title: string; body: string; translations: Partial> }, lang: Lang, source: Lang, ): Shown { if (lang === source) return { title: v.title, body: v.body, translated: false, stale: false, missing: false }; const tr = v.translations[lang]; if (!tr) return { title: v.title, body: v.body, translated: false, stale: false, missing: true }; return { title: tr.title, body: tr.body, translated: true, stale: tr.stale, missing: false }; } export class DocState { view = $state(null); error = $state(null); mode = $state('inforce'); preview = $state(null); original = $state(false); async load(group: string, slug: string) { try { const g = await api.get(`/groups/${group}`); const d = g.docs.find((x) => x.slug === slug); if (!d) { this.error = g.group.open ? 'not_found' : 'forbidden'; return; } this.view = await api.get(`/docs/${d.id}`); this.error = null; if (this.mode !== 'inforce') await this.loadPreview(); } catch (e) { this.error = e instanceof ApiError ? e.code : 'internal'; } } async reload() { if (!this.view) return; this.view = await api.get(`/docs/${this.view.doc.id}`); if (this.mode !== 'inforce') await this.loadPreview(); } async setMode(mode: View) { this.mode = mode; if (mode === 'inforce') this.preview = null; else await this.loadPreview(); } async loadPreview() { if (!this.view) return; const r = await api.get<{ units: PreviewUnit[] }>(`/docs/${this.view.doc.id}/preview?mode=${this.mode}`); this.preview = r.units; } /** The modules of the document (ADR 0009): its title, then the order of each section. */ get modules(): Unit[] { const m = this.view?.modules; return m ? [...(m.title ? [m.title] : []), ...m.orders] : []; } /** The order of a section, when it has something to decide (two articles or more). */ orderOf(section: string | null): Unit | undefined { return this.view?.modules.orders.find((o) => o.section === section && !o.moot); } /** An article or a module. */ unit(id: string | null): Unit | undefined { return this.view?.units.find((u) => u.id === id) ?? this.modules.find((u) => u.id === id); } proposal(id: string | null): Proposal | undefined { if (!id || !this.view) return undefined; for (const u of [...this.view.units, ...this.modules]) { if (u.inForce?.id === id) return u.inForce; const p = u.proposals.find((x) => x.id === id); if (p) return p; } return undefined; } get inForce(): Unit[] { return (this.view?.units ?? []).filter((u) => u.inForce); } /** * Articles and modules in document order (ADR 0009): the title first, then the * articles of each section followed by its order, when it has something to decide. */ get ordered(): Unit[] { const m = this.view?.modules; const out: Unit[] = m?.title ? [m.title] : []; const units = this.view?.units ?? []; units.forEach((u, i) => { out.push(u); if (units[i + 1]?.section === u.section) return; const order = this.orderOf(u.section); if (order) out.push(order); }); return out; } /** * The text in force by section, each closed by its order (ADR 0009) — shown even * where no article is in force yet, as long as it has something to decide. */ get inForceBySection(): { section: string | null; units: Unit[]; order: Unit | undefined }[] { const out: { section: string | null; units: Unit[]; order: Unit | undefined }[] = []; for (const u of this.view?.units ?? []) { let last = out.at(-1); if (!last || last.section !== u.section) { last = { section: u.section, units: [], order: this.orderOf(u.section) }; out.push(last); } if (u.inForce) last.units.push(u); } return out.filter((s) => s.units.length || s.order); } /** Open proposals, of articles and modules, in document order; drafts apart. */ get open(): { unit: Unit; proposal: Proposal }[] { const out: { unit: Unit; proposal: Proposal }[] = []; for (const unit of this.ordered) { for (const proposal of unit.proposals) if (proposal.status === 'open') out.push({ unit, proposal }); } return out; } /** The title of an article as it is read: its text in force, else its latest open text. */ articleTitle(id: string, lang: Lang, source: Lang): string { const u = this.view?.units.find((x) => x.id === id); if (!u) return id; const v = u.inForce ?? u.proposals.findLast((p) => p.status === 'open' && p.kind === 'text'); return v ? shown(v, lang, source).title : u.code; } get drafts(): { unit: Unit; proposal: Proposal }[] { const out: { unit: Unit; proposal: Proposal }[] = []; for (const unit of this.view?.units ?? []) { for (const proposal of unit.proposals) if (proposal.status === 'draft') out.push({ unit, proposal }); } return out; } }