Meldingen-hardening: verwerp-optie bij sluiten, classificatie-gate, presentatietimer #7
13 changed files with 259 additions and 4 deletions
|
|
@ -41,6 +41,12 @@ and the project aims to follow [Semantic Versioning](https://semver.org/).
|
||||||
- **Per-slide TLP classification** — each slide can carry its own Traffic Light
|
- **Per-slide TLP classification** — each slide can carry its own Traffic Light
|
||||||
Protocol level; slides classified stricter than the level the deck is shown at
|
Protocol level; slides classified stricter than the level the deck is shown at
|
||||||
are withheld when presenting and exporting.
|
are withheld when presenting and exporting.
|
||||||
|
- **Export release ceiling** — an optional maximum TLP level that may be
|
||||||
|
exported. When set, a deck classified *above* it cannot be exported in any
|
||||||
|
format; the gate is enforced at the single export chokepoint and fails closed
|
||||||
|
(no file is written when blocked, and the export dialog explains why).
|
||||||
|
Classifying a deck stays optional — the ceiling only stops decks that exceed
|
||||||
|
it, and it is off by default.
|
||||||
- **Dual-screen presenter** — on a second display the beamer shows the slide
|
- **Dual-screen presenter** — on a second display the beamer shows the slide
|
||||||
while the laptop shows the presenter view (current/next slide, notes, timer),
|
while the laptop shows the presenter view (current/next slide, notes, timer),
|
||||||
kept in sync over method channels.
|
kept in sync over method channels.
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Built with Flutter for macOS, Windows, and Linux.
|
||||||
- **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background, text colour and monospace font come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). The code auto-sizes to fill the panel.
|
- **Source-code slides** — a "code sheet" with per-language syntax highlighting, stored as a fenced code block. Background, text colour and monospace font come from the style profile, with an optional syntax-colouring toggle (turn it off for a single-colour, CRT-terminal look). The code auto-sizes to fill the panel.
|
||||||
- **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor.
|
- **Charts** — bar, line, pie, and spider/radar charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory. Optional min/max draw reference lines (bar/line) or fix the scale (radar); legend hover highlights a series, and line tooltips pick the dot under the cursor.
|
||||||
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math.
|
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math.
|
||||||
- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting.
|
- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting. An optional **export release ceiling** can block exporting any deck classified above a chosen level — enforced for every format, off by default.
|
||||||
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview.
|
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview.
|
||||||
- **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync.
|
- **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync.
|
||||||
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
|
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ OciDeck is an offline desktop application. Areas of particular interest:
|
||||||
- Importing presentations from a URL.
|
- Importing presentations from a URL.
|
||||||
- The HTML export, which inlines third-party JavaScript (marked, highlight.js,
|
- The HTML export, which inlines third-party JavaScript (marked, highlight.js,
|
||||||
mermaid, MathJax) to render offline.
|
mermaid, MathJax) to render offline.
|
||||||
|
- The export classification gate (`ClassificationPolicy`) — any way to export a
|
||||||
|
deck classified above the configured release ceiling.
|
||||||
|
|
||||||
## Supported versions
|
## Supported versions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md).
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
|
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
|
||||||
services/ # markdown, file, export, image, caption, description,
|
services/ # markdown, file, export, classification_policy, image, caption,
|
||||||
# image_dedup (md5 duplicates), image_reference (.md rewrites),
|
# description, image_dedup (md5 duplicates),
|
||||||
# recovery, rasterizer, marp_html, annotation_codec
|
# image_reference (.md rewrites), recovery, rasterizer,
|
||||||
|
# marp_html, annotation_codec
|
||||||
state/ # Riverpod providers: deck, editor, settings, tabs, clipboard
|
state/ # Riverpod providers: deck, editor, settings, tabs, clipboard
|
||||||
widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter
|
widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter
|
||||||
l10n/ # AppLocalizations (8 languages)
|
l10n/ # AppLocalizations (8 languages)
|
||||||
|
|
@ -66,6 +67,15 @@ the key thing to understand before touching rendering:
|
||||||
**SVG in Dart** here (no JS chart library). Fidelity differs from the in-app
|
**SVG in Dart** here (no JS chart library). Fidelity differs from the in-app
|
||||||
renderer by design.
|
renderer by design.
|
||||||
|
|
||||||
|
Both worlds converge at one chokepoint: `services/export_service.dart`
|
||||||
|
(`ExportService.export()`) is the only place that writes an export, so the
|
||||||
|
**classification gate** lives there rather than in the export dialog. A
|
||||||
|
`ClassificationPolicy` enforces an optional *release ceiling* and refuses,
|
||||||
|
**fail-closed**, to export a deck classified above it — no format can bypass it.
|
||||||
|
The ceiling is stored in app settings (`maxReleaseExportTlpKey`, off by default);
|
||||||
|
the dialog also runs the same check up front so a blocked export is explained
|
||||||
|
before any work starts.
|
||||||
|
|
||||||
## Presenter
|
## Presenter
|
||||||
|
|
||||||
`widgets/presentation/fullscreen_presenter.dart` drives presenting:
|
`widgets/presentation/fullscreen_presenter.dart` drives presenting:
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,10 @@ A deck has an overall TLP level (shown as a marking on the slides). Each slide c
|
||||||
deck can be shown safely to audiences with different clearances. Order, least to
|
deck can be shown safely to audiences with different clearances. Order, least to
|
||||||
most restrictive: none < CLEAR < GREEN < AMBER < AMBER+STRICT < RED.
|
most restrictive: none < CLEAR < GREEN < AMBER < AMBER+STRICT < RED.
|
||||||
|
|
||||||
|
Classifying a deck is **optional**. As an extra guardrail, an organisation can
|
||||||
|
set a **release ceiling** — a maximum level that may leave the machine; see
|
||||||
|
*Exporting* below.
|
||||||
|
|
||||||
## Presenting
|
## Presenting
|
||||||
|
|
||||||
Start the fullscreen presenter from the toolbar. See
|
Start the fullscreen presenter from the toolbar. See
|
||||||
|
|
@ -148,6 +152,11 @@ Export to:
|
||||||
- **Portable package** (`.ocideck`) — a single zip with the Markdown and all
|
- **Portable package** (`.ocideck`) — a single zip with the Markdown and all
|
||||||
assets, to hand the whole deck to someone else.
|
assets, to hand the whole deck to someone else.
|
||||||
|
|
||||||
|
**Release ceiling (optional).** When a maximum TLP level is configured, exporting
|
||||||
|
a deck classified *above* it is blocked for every format, and the export dialog
|
||||||
|
explains why. The ceiling is off by default and classifying a deck stays
|
||||||
|
optional — it only stops decks that exceed the configured level.
|
||||||
|
|
||||||
## Accessibility
|
## Accessibility
|
||||||
|
|
||||||
OciDeck aims for WCAG 2.1 in the editor:
|
OciDeck aims for WCAG 2.1 in the editor:
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,12 @@ class AppSettings {
|
||||||
final String selectedAppAppearanceProfileName;
|
final String selectedAppAppearanceProfileName;
|
||||||
final List<String> recentFiles;
|
final List<String> recentFiles;
|
||||||
|
|
||||||
|
/// Optioneel vrijgaveplafond voor de classificatie-gate, opgeslagen als
|
||||||
|
/// TLP-sleutel (zie `TlpLevelX.key`). `null` = geen plafond, alles mag worden
|
||||||
|
/// geëxporteerd (standaard). Classificeren blijft optioneel; dit plafond
|
||||||
|
/// blokkeert alleen decks die er bovenuit zijn geclassificeerd.
|
||||||
|
final String? maxReleaseExportTlpKey;
|
||||||
|
|
||||||
/// Scale factor for all interface text (1.0–2.0), on top of the system
|
/// Scale factor for all interface text (1.0–2.0), on top of the system
|
||||||
/// text scaling. The slide canvas itself is never scaled: slides are a
|
/// text scaling. The slide canvas itself is never scaled: slides are a
|
||||||
/// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%.
|
/// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%.
|
||||||
|
|
@ -378,6 +384,7 @@ class AppSettings {
|
||||||
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
|
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
|
||||||
this.selectedAppAppearanceProfileName = 'Basic',
|
this.selectedAppAppearanceProfileName = 'Basic',
|
||||||
this.recentFiles = const [],
|
this.recentFiles = const [],
|
||||||
|
this.maxReleaseExportTlpKey,
|
||||||
this.uiTextScale = 1.0,
|
this.uiTextScale = 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -430,9 +437,11 @@ class AppSettings {
|
||||||
List<AppAppearanceProfile>? appAppearanceProfiles,
|
List<AppAppearanceProfile>? appAppearanceProfiles,
|
||||||
String? selectedAppAppearanceProfileName,
|
String? selectedAppAppearanceProfileName,
|
||||||
List<String>? recentFiles,
|
List<String>? recentFiles,
|
||||||
|
String? maxReleaseExportTlpKey,
|
||||||
double? uiTextScale,
|
double? uiTextScale,
|
||||||
bool clearHomeDirectory = false,
|
bool clearHomeDirectory = false,
|
||||||
bool clearExportDirectory = false,
|
bool clearExportDirectory = false,
|
||||||
|
bool clearMaxReleaseExportTlp = false,
|
||||||
}) {
|
}) {
|
||||||
final nextProfiles = themeProfiles ?? this.themeProfiles;
|
final nextProfiles = themeProfiles ?? this.themeProfiles;
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
|
|
@ -464,6 +473,9 @@ class AppSettings {
|
||||||
selectedAppAppearanceProfileName ??
|
selectedAppAppearanceProfileName ??
|
||||||
this.selectedAppAppearanceProfileName,
|
this.selectedAppAppearanceProfileName,
|
||||||
recentFiles: recentFiles ?? this.recentFiles,
|
recentFiles: recentFiles ?? this.recentFiles,
|
||||||
|
maxReleaseExportTlpKey: clearMaxReleaseExportTlp
|
||||||
|
? null
|
||||||
|
: (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey),
|
||||||
uiTextScale: uiTextScale ?? this.uiTextScale,
|
uiTextScale: uiTextScale ?? this.uiTextScale,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
lib/services/classification_policy.dart
Normal file
58
lib/services/classification_policy.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import '../models/deck.dart';
|
||||||
|
|
||||||
|
/// Uitkomst van de export-gate: mag deze export door, en zo niet, waarom niet.
|
||||||
|
class ExportDecision {
|
||||||
|
/// Of de export is toegestaan.
|
||||||
|
final bool allowed;
|
||||||
|
|
||||||
|
/// Reden waarom de export geweigerd is (`null` wanneer toegestaan). Bedoeld om
|
||||||
|
/// 1-op-1 aan de gebruiker te tonen.
|
||||||
|
final String? reason;
|
||||||
|
|
||||||
|
const ExportDecision._(this.allowed, this.reason);
|
||||||
|
|
||||||
|
const ExportDecision.allow() : this._(true, null);
|
||||||
|
const ExportDecision.block(String reason) : this._(false, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centrale, pure beslisser voor classificatie bij export.
|
||||||
|
///
|
||||||
|
/// Classificeren is **optioneel**: een deck zonder TLP-niveau ([TlpLevel.none])
|
||||||
|
/// exporteert altijd. Maar zodra een organisatie een vrijgaveplafond instelt, is
|
||||||
|
/// dit de enige plek die bepaalt of een geclassificeerd deck naar buiten mag.
|
||||||
|
///
|
||||||
|
/// De gate hangt aan het export-chokepoint ([ExportService.export]), zodat geen
|
||||||
|
/// enkel formaat (PDF/PPTX/HTML) eromheen kan. Fail-closed: bij twijfel weigert
|
||||||
|
/// de gate in plaats van stilletjes te exporteren.
|
||||||
|
class ClassificationPolicy {
|
||||||
|
/// Hoogste TLP-niveau dat geëxporteerd mag worden — het vrijgaveplafond.
|
||||||
|
///
|
||||||
|
/// `null` = geen plafond, alles mag (standaard). Een deck dat híérboven is
|
||||||
|
/// geclassificeerd wordt geweigerd in plaats van naar buiten gebracht. Let op:
|
||||||
|
/// een plafond van [TlpLevel.none] staat alléén ongeclassificeerde decks toe.
|
||||||
|
final TlpLevel? maxReleaseLevel;
|
||||||
|
|
||||||
|
const ClassificationPolicy({this.maxReleaseLevel});
|
||||||
|
|
||||||
|
/// Bouw het beleid uit de opgeslagen instelling: een TLP-sleutel (zie
|
||||||
|
/// [TlpLevelX.key]) of `null` wanneer er geen plafond is ingesteld.
|
||||||
|
factory ClassificationPolicy.fromKey(String? key) => ClassificationPolicy(
|
||||||
|
maxReleaseLevel: key == null ? null : TlpLevelX.fromKey(key),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Of er überhaupt een gate actief is.
|
||||||
|
bool get hasGate => maxReleaseLevel != null;
|
||||||
|
|
||||||
|
/// Beoordeel of een deck met niveau [deckLevel] geëxporteerd mag worden.
|
||||||
|
ExportDecision evaluate(TlpLevel deckLevel) {
|
||||||
|
final ceiling = maxReleaseLevel;
|
||||||
|
if (ceiling != null && deckLevel.index > ceiling.index) {
|
||||||
|
return ExportDecision.block(
|
||||||
|
'Export geblokkeerd door classificatiebeleid: dit deck is '
|
||||||
|
'${deckLevel.label}, hoger dan het toegestane vrijgaveniveau '
|
||||||
|
'${ceiling.label}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const ExportDecision.allow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,9 @@ import 'package:path/path.dart' as p;
|
||||||
import 'package:pdf/pdf.dart';
|
import 'package:pdf/pdf.dart';
|
||||||
import 'package:pdf/widgets.dart' as pw;
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|
||||||
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
import 'classification_policy.dart';
|
||||||
import 'marp_html_service.dart';
|
import 'marp_html_service.dart';
|
||||||
|
|
||||||
enum ExportFormat { pdf, pptx, html }
|
enum ExportFormat { pdf, pptx, html }
|
||||||
|
|
@ -103,7 +105,17 @@ class ExportService {
|
||||||
List<String>? notes,
|
List<String>? notes,
|
||||||
String? markdown,
|
String? markdown,
|
||||||
ThemeProfile? themeProfile,
|
ThemeProfile? themeProfile,
|
||||||
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
ClassificationPolicy policy = const ClassificationPolicy(),
|
||||||
}) async {
|
}) async {
|
||||||
|
// Classificatie-gate. Dit is het enige chokepoint waar elk formaat
|
||||||
|
// (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de
|
||||||
|
// UI-laag: zo kan geen exportpad de gate omzeilen. Fail-closed — bij een
|
||||||
|
// weigering wordt er niets gebouwd of weggeschreven.
|
||||||
|
final decision = policy.evaluate(tlp);
|
||||||
|
if (!decision.allowed) {
|
||||||
|
return ExportResult.fail(decision.reason!);
|
||||||
|
}
|
||||||
if (format == ExportFormat.html) {
|
if (format == ExportFormat.html) {
|
||||||
if (markdown == null || markdown.trim().isEmpty) {
|
if (markdown == null || markdown.trim().isEmpty) {
|
||||||
return ExportResult.fail('Geen inhoud om te exporteren.');
|
return ExportResult.fail('Geen inhoud om te exporteren.');
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,25 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
? selectedAppearance
|
? selectedAppearance
|
||||||
: 'Basic',
|
: 'Basic',
|
||||||
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
||||||
|
maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'),
|
||||||
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
|
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stel het vrijgaveplafond voor de export-gate in (een TLP-sleutel), of
|
||||||
|
/// `null` om de gate uit te zetten. Persisteert in hetzelfde prefs-domein.
|
||||||
|
Future<void> setMaxReleaseExportTlp(String? key) async {
|
||||||
|
state = key == null
|
||||||
|
? state.copyWith(clearMaxReleaseExportTlp: true)
|
||||||
|
: state.copyWith(maxReleaseExportTlpKey: key);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (key == null) {
|
||||||
|
await prefs.remove('maxReleaseExportTlp');
|
||||||
|
} else {
|
||||||
|
await prefs.setString('maxReleaseExportTlp', key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setUiTextScale(double scale) async {
|
Future<void> setUiTextScale(double scale) async {
|
||||||
final clamped = scale.clamp(1.0, 2.0).toDouble();
|
final clamped = scale.clamp(1.0, 2.0).toDouble();
|
||||||
state = state.copyWith(uiTextScale: clamped);
|
state = state.copyWith(uiTextScale: clamped);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import '../models/deck.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
import '../services/caption_service.dart';
|
import '../services/caption_service.dart';
|
||||||
import '../services/description_service.dart';
|
import '../services/description_service.dart';
|
||||||
|
import '../services/classification_policy.dart';
|
||||||
import '../services/export_service.dart';
|
import '../services/export_service.dart';
|
||||||
import '../services/recovery_service.dart';
|
import '../services/recovery_service.dart';
|
||||||
import '../state/deck_provider.dart';
|
import '../state/deck_provider.dart';
|
||||||
|
|
@ -560,6 +561,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
projectPath: deck.projectPath,
|
projectPath: deck.projectPath,
|
||||||
exportService: widget.exportService,
|
exportService: widget.exportService,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
policy: ClassificationPolicy.fromKey(
|
||||||
|
ref.read(settingsProvider).maxReleaseExportTlpKey,
|
||||||
|
),
|
||||||
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
||||||
// Inline chart data so the HTML export can render charts standalone,
|
// Inline chart data so the HTML export can render charts standalone,
|
||||||
// even when a chart links an external CSV.
|
// even when a chart links an external CSV.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
|
import '../../services/classification_policy.dart';
|
||||||
import '../../services/export_service.dart';
|
import '../../services/export_service.dart';
|
||||||
import '../../services/slide_rasterizer.dart';
|
import '../../services/slide_rasterizer.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
@ -18,6 +19,9 @@ class ExportDialog extends StatefulWidget {
|
||||||
final ExportService exportService;
|
final ExportService exportService;
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
|
||||||
|
/// Classificatie-gate. Standaard geen plafond (alles mag).
|
||||||
|
final ClassificationPolicy policy;
|
||||||
|
|
||||||
/// Folder all exports are written to. Null = next to the source deck.
|
/// Folder all exports are written to. Null = next to the source deck.
|
||||||
final String? exportDirectory;
|
final String? exportDirectory;
|
||||||
|
|
||||||
|
|
@ -32,6 +36,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
required this.projectPath,
|
required this.projectPath,
|
||||||
required this.exportService,
|
required this.exportService,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.policy = const ClassificationPolicy(),
|
||||||
this.exportDirectory,
|
this.exportDirectory,
|
||||||
this.markdown = '',
|
this.markdown = '',
|
||||||
});
|
});
|
||||||
|
|
@ -44,6 +49,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
required String? projectPath,
|
required String? projectPath,
|
||||||
required ExportService exportService,
|
required ExportService exportService,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
ClassificationPolicy policy = const ClassificationPolicy(),
|
||||||
String? exportDirectory,
|
String? exportDirectory,
|
||||||
String markdown = '',
|
String markdown = '',
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -57,6 +63,7 @@ class ExportDialog extends StatefulWidget {
|
||||||
projectPath: projectPath,
|
projectPath: projectPath,
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
policy: policy,
|
||||||
exportDirectory: exportDirectory,
|
exportDirectory: exportDirectory,
|
||||||
markdown: markdown,
|
markdown: markdown,
|
||||||
),
|
),
|
||||||
|
|
@ -131,6 +138,8 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
notes: [for (final s in widget.slides) s.notes],
|
notes: [for (final s in widget.slides) s.notes],
|
||||||
markdown: widget.markdown,
|
markdown: widget.markdown,
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
|
tlp: widget.tlp,
|
||||||
|
policy: widget.policy,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -231,6 +240,25 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-flight classificatie-gate: blokkeert de export al vóór een poging,
|
||||||
|
// zodat de gebruiker meteen de reden ziet. De service handhaaft dezelfde
|
||||||
|
// regel nog eens als backstop, dus dit is puur UX — niet de beveiliging.
|
||||||
|
final decision = widget.policy.evaluate(widget.tlp);
|
||||||
|
if (!decision.allowed) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.block, color: Colors.red, size: 36),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
decision.reason!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.red[800]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
|
||||||
62
test/classification_policy_test.dart
Normal file
62
test/classification_policy_test.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/deck.dart';
|
||||||
|
import 'package:ocideck/services/classification_policy.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ClassificationPolicy', () {
|
||||||
|
test(
|
||||||
|
'without a ceiling every level is allowed (classificeren optioneel)',
|
||||||
|
() {
|
||||||
|
const policy = ClassificationPolicy();
|
||||||
|
expect(policy.hasGate, isFalse);
|
||||||
|
for (final level in TlpLevel.values) {
|
||||||
|
expect(policy.evaluate(level).allowed, isTrue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a ceiling allows levels at or below it', () {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
|
||||||
|
expect(policy.hasGate, isTrue);
|
||||||
|
for (final level in [
|
||||||
|
TlpLevel.none,
|
||||||
|
TlpLevel.clear,
|
||||||
|
TlpLevel.green,
|
||||||
|
TlpLevel.amber,
|
||||||
|
]) {
|
||||||
|
expect(policy.evaluate(level).allowed, isTrue, reason: level.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ceiling blocks levels above it, with a clear reason', () {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
|
||||||
|
for (final level in [TlpLevel.amberStrict, TlpLevel.red]) {
|
||||||
|
final decision = policy.evaluate(level);
|
||||||
|
expect(decision.allowed, isFalse, reason: level.name);
|
||||||
|
expect(decision.reason, contains(level.label));
|
||||||
|
expect(decision.reason, contains(TlpLevel.amber.label));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ceiling of none only allows unclassified decks', () {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.none);
|
||||||
|
expect(policy.evaluate(TlpLevel.none).allowed, isTrue);
|
||||||
|
expect(policy.evaluate(TlpLevel.clear).allowed, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fromKey', () {
|
||||||
|
test('null key means no gate', () {
|
||||||
|
final policy = ClassificationPolicy.fromKey(null);
|
||||||
|
expect(policy.hasGate, isFalse);
|
||||||
|
expect(policy.evaluate(TlpLevel.red).allowed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a TLP key sets the ceiling', () {
|
||||||
|
final policy = ClassificationPolicy.fromKey(TlpLevel.green.key);
|
||||||
|
expect(policy.maxReleaseLevel, TlpLevel.green);
|
||||||
|
expect(policy.evaluate(TlpLevel.green).allowed, isTrue);
|
||||||
|
expect(policy.evaluate(TlpLevel.amber).allowed, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ import 'dart:typed_data';
|
||||||
import 'package:archive/archive.dart';
|
import 'package:archive/archive.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:ocideck/models/deck.dart';
|
||||||
|
import 'package:ocideck/services/classification_policy.dart';
|
||||||
import 'package:ocideck/services/export_service.dart';
|
import 'package:ocideck/services/export_service.dart';
|
||||||
import 'package:ocideck/services/marp_html_service.dart';
|
import 'package:ocideck/services/marp_html_service.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
@ -56,6 +58,41 @@ void main() {
|
||||||
|
|
||||||
String deckPath() => p.join(tmp.path, 'deck.md');
|
String deckPath() => p.join(tmp.path, 'deck.md');
|
||||||
|
|
||||||
|
test(
|
||||||
|
'classificatie-gate blocks an over-classified export, writes nothing',
|
||||||
|
() async {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.green);
|
||||||
|
final r = await service.export(
|
||||||
|
deckPath(),
|
||||||
|
ExportFormat.pdf,
|
||||||
|
[_png()],
|
||||||
|
tlp: TlpLevel.red,
|
||||||
|
policy: policy,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.success, isFalse);
|
||||||
|
expect(r.outputPath, isNull);
|
||||||
|
expect(r.error, contains('classificatiebeleid'));
|
||||||
|
// Fail-closed: no file may be produced when the gate refuses.
|
||||||
|
final produced = tmp.listSync().whereType<File>().where(
|
||||||
|
(f) => p.extension(f.path) == '.pdf',
|
||||||
|
);
|
||||||
|
expect(produced, isEmpty);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('classificatie-gate allows an export at or below the ceiling', () async {
|
||||||
|
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
|
||||||
|
final r = await service.export(
|
||||||
|
deckPath(),
|
||||||
|
ExportFormat.pdf,
|
||||||
|
[_png()],
|
||||||
|
tlp: TlpLevel.green,
|
||||||
|
policy: policy,
|
||||||
|
);
|
||||||
|
expect(r.success, isTrue, reason: r.error);
|
||||||
|
});
|
||||||
|
|
||||||
test('exports a PDF that starts with the PDF magic header', () async {
|
test('exports a PDF that starts with the PDF magic header', () async {
|
||||||
final images = [_png(), _png()];
|
final images = [_png(), _png()];
|
||||||
final r = await service.export(deckPath(), ExportFormat.pdf, images);
|
final r = await service.export(deckPath(), ExportFormat.pdf, images);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue