diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c3fd0..3183bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ All notable Doc2FAQ changes are recorded here. Version numbers follow semantic v - Show the full build version in the preview heading and footer, populated from `pom.xml` during the Maven build so future releases stay in sync. +## 0.4.0 (unreleased) + +### Added + +- Cursor-responsive ambient background with reduced-motion and page-visibility support. +- Document title help, separate editable download filename, and Save work labels that explain how to resume edits. +- Clear document control that also removes the saved source and discards late imports, FAQ responses, and exports. +- Original source filename defaults such as `TEst Data.pdf` to `testdata-Doc2FAQ.pdf`, preserved in saved work and supported by export response headers. + +- Versioned project files that save/reopen source text, title, FAQ edits/order, original excerpts, and review choices, including incomplete entries. +- Optional browser autosave/restore with clear saved data controls; saving is off by default. +- Exact source excerpts and extracted-text line references for both drafting modes, with edited-answer labels. +- Similar-question review, keep/merge controls, and undo for removals/merges. Merges retain the original references. +- PDF and DOCX downloads with title metadata, page numbers, Unicode font support, optional references, and validated input limits. +- Safe content preview and configurable titles for all exports. +- Project/schema/merge tests, source-reference tests, and export API tests for Unicode, pagination, validation, and unsupported PDF glyphs. + +### Changed + +- PDF uses embedded Lato Regular/Bold fonts and a centered title; PDF and Word exports omit the FAQ-count subtitle. + +- Distinct answers under the same topic or question are retained for manual review instead of silently discarded. Exact question/answer duplicates remain filtered; the draft cap remains 30. +- Answer editing now supports up to 50,000 characters, consistent with imported text. +- Pending draft results are invalidated when draft entries are edited, added, moved, removed, merged, or restored. Delayed file imports cannot replace newer source/project changes. + ## 0.3.1 ### Fixed diff --git a/README.md b/README.md index 0001ba9..e83297c 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,24 @@ **Turn documents into editable FAQ drafts — locally, with full control over the answers.** -Doc2FAQ is a Java 21 / Spring Boot document-to-FAQ workbench. Paste text or upload TXT, Markdown, PDF, or DOCX, then extract existing questions or create source-grounded suggestions from ordinary document sections. Review, edit, reorder, and export your results as **Markdown, JSON, or HTML**. +Doc2FAQ is a Java 21 / Spring Boot document-to-FAQ workbench. Paste text or upload TXT, Markdown, PDF, or DOCX, then extract existing questions or create source-grounded suggestions from ordinary document sections. Review source excerpts, edit and reorder your FAQs, save a project, and export as **Markdown, JSON, HTML, PDF, or Word (DOCX)**. Created by **Okan Tiktas** · [OKAN-SOFTWARE](https://github.com/OKAN-SOFTWARE). -> **Source version: v0.3.1.** The version in `pom.xml` identifies this build. For published tags, release downloads, and announcements, see [GitHub Releases](https://github.com/OKAN-SOFTWARE/Doc2FAQ/releases). This source version does not by itself publish a GitHub Release. +> **Development version: v0.4.0-SNAPSHOT.** The version in `pom.xml` identifies this build. For published tags, release downloads, and announcements, see [GitHub Releases](https://github.com/OKAN-SOFTWARE/Doc2FAQ/releases). This source version does not by itself publish a GitHub Release. + +## New in the v0.4.0 development build + +- **Save work / Open saved work:** a versioned JSON file preserves source text, title, mode, draft order, edits, original excerpts, and duplicate-review choices. Incomplete FAQs can be saved too. Project files are limited to 4 MB and validated before replacing the workspace. +- **Optional browser autosave:** off by default. Enabling it stores the complete source and draft on this browser's origin and restores them on reload. Disabling autosave or using **Clear saved data** removes Doc2FAQ's saved data without clearing your open workspace. Storage failures leave the draft open; use **Save work** as a backup. +- **Source references:** expand **Show source** to see the original section, extracted-text line range, and exact passage. Edited questions and answers are marked for rechecking. References use text lines, not PDF page numbers; manually added FAQs have no automatic reference. +- **Duplicate review:** distinct passages are retained even if their questions cover the same topic. Similar questions are flagged using a word-overlap heuristic. Choose **Keep both** or **Merge into this FAQ**. Merging keeps both answers and all source references; **Undo removal / merge** restores the previous items until another FAQ edit, addition, reorder, or review choice. Exact duplicate question/answer pairs are still filtered. The maximum remains 30 FAQs. +- **PDF and Word export:** edited content, a centered document title, readable page layout, metadata, and page numbers. Optionally include section/line references and edited-answer notices. PDF embeds Lato Regular and Bold (see `src/main/resources/fonts/LICENSE-Lato.txt`); if text contains a glyph that font cannot represent, export returns an error and suggests DOCX. +- **Export preview:** inspect the final content before downloading. PDF and Word apply their own pagination. Markdown, JSON, and standalone HTML export remain available; binary exports use **Download**. + +- **Clear document:** removes the loaded source, FAQ draft, file names, pending results, and its saved browser copy. The autosave preference is retained. A late import or export cannot bring back the cleared PDF. +- **Original filenames:** importing `TEst Data.pdf` pre-fills the document title and the download name `testdata-Doc2FAQ.pdf`. Edit **Download filename** before exporting; saved work preserves that choice. Names are sanitized for Windows and the extension follows the selected format. +- **Interactive background:** gentle particles and a cursor-responsive glow, paused while the page is hidden. Reduced-motion preferences use a still background. ## What's fixed in v0.3.1 @@ -26,7 +39,7 @@ The document placeholder now uses neutral Q&A prompts, and Generate Mode's sampl - **Consistent 50,000-character limit** across document extraction, the browser, and both FAQ API endpoints. - **Windows CMD setup** for people who don't have Java or Maven installed, plus troubleshooting. -**Privacy and accuracy:** The current Generate Mode is **deterministic and rule-based, not a generative AI model**. No API key, paid AI service, or external model is required. Its questions are suggestions; answers come from excerpts in your source document. Topic-based duplicate filtering can sometimes suppress distinct details within a topic. It cannot reliably interpret every layout or guarantee an accurate summary. Always review before publishing. +**Privacy and accuracy:** The current Generate Mode is **deterministic and rule-based, not a generative AI model**. No API key, paid AI service, or external model is required. Its questions are suggestions; answers come from excerpts in your source document. Similar-question flags are suggestions and can include questions you want to keep. It cannot reliably interpret every layout or guarantee an accurate summary. Always review before publishing. For detailed changes, see [CHANGELOG.md](CHANGELOG.md). For the release checklist, see [docs/RELEASING.md](docs/RELEASING.md). @@ -150,7 +163,7 @@ After updating your checkout to the latest `main`, run these commands from the d ```cmd mvn -B -ntp clean verify node --test src/test/js/*.test.cjs -java -jar target/doc2faq-0.3.1.jar +java -jar target/doc2faq-0.4.0-SNAPSHOT.jar ``` The JavaScript checks require Node.js. Keep the terminal open while the app runs, and use **Ctrl+C** to stop it. Open **http://localhost:8080** and refresh the browser to load the updated frontend. @@ -158,7 +171,7 @@ The JavaScript checks require Node.js. Keep the terminal open while the app runs If port 8080 is occupied, stop the previous Doc2FAQ process or use an alternate port: ```cmd -java -jar target/doc2faq-0.3.1.jar --server.port=8081 +java -jar target/doc2faq-0.4.0-SNAPSHOT.jar --server.port=8081 ``` Then open **http://localhost:8081**. Merging source changes does not update an existing JAR or a running Java process; rebuild and restart to use the fixes. Published JARs must be built from the corresponding release commit. @@ -170,8 +183,9 @@ The preview heading and footer display the full project version from `pom.xml`. 1. Paste your document's text or choose a `.txt`, `.md`, `.pdf`, or `.docx` file. 2. Select **Extract Mode** if your text already contains FAQ questions; select **Generate Mode** for a normal document with section headings. 3. Click **Extract FAQ draft** or **Generate FAQ draft**, depending on the mode. -4. Review and edit each question and answer. Use arrows to reorder, **Remove** to delete, and **Add FAQ** to add an entry. -5. Choose **Markdown**, **JSON**, or **HTML**, then **Copy** or **Download**. Blank questions or answers must be completed before export. +4. Expand **Show source** and review each question and answer. Reorder, remove, or add entries. Review similar questions and use Undo after a removal or merge. +5. Set the document title printed on your FAQ and use **Save work** to keep the source and your edits. Use **Open saved work** to continue later. Optionally enable browser autosave. +6. Choose **Markdown**, **JSON**, **HTML**, **PDF**, or **Word**, optionally include references, and inspect **Preview export**. Use **Copy** for text formats or **Download** for any format. Blank questions or answers must be completed before export. Example document for **Generate Mode**: @@ -193,7 +207,7 @@ The team provides onboarding guides and a shared knowledge base. Members share guides on document organization and clear technical writing. ``` -Generate Mode preserves explicit FAQ pairs, then suggests questions for additional sections while filtering repeated common topics and boilerplate appendix headings. The answer text is sourced from the document. Generated question phrasing is heuristic; this is *not* legal advice or an automated contract interpretation service. +Generate Mode preserves explicit FAQ pairs and suggests questions for additional sections. Exact duplicate pairs and boilerplate appendix headings are filtered; distinct passages remain available for review. The answer text is sourced from the document. Generated question phrasing is heuristic; this is *not* legal advice or an automated contract interpretation service. ## Formats and limits @@ -203,6 +217,8 @@ Generate Mode preserves explicit FAQ pairs, then suggests questions for addition | PDF | Text extraction using Apache PDFBox | 5 MB upload, 50 pages, 50,000 extracted characters | | DOCX | Text extraction using Apache POI | 5 MB upload, 50,000 extracted characters | | FAQ drafts | Local rules based on source text | Maximum 30 suggested/extracted pairs | +| Project files | Validated JSON, read in the browser | 4 MB, 30 FAQs, 50,000 source characters | +| Exports | Text formats in browser; PDF/DOCX on the server | Title 200 characters; question 500; answer 50,000; combined FAQ text 300,000 | The server checks file signatures, not just extensions. Empty, corrupted, password-protected, and unsupported documents are rejected with clear errors. **Scanned/image-only PDFs require OCR**, which this version does not provide. Complex tables and multi-column layouts can lead to imperfect text order; inspect the extracted text before drafting. @@ -214,6 +230,8 @@ The server checks file signatures, not just extensions. Empty, corrupted, passwo | POST | `/api/documents/extract` | Multipart PDF/DOCX to extracted text | | POST | `/api/faqs/preview` | Extract explicit questions and answers | | POST | `/api/faqs/generate` | Suggest source-grounded FAQs using local rules | +| POST | `/api/faqs/export/pdf` | Validated FAQ JSON to downloadable PDF | +| POST | `/api/faqs/export/docx` | Validated FAQ JSON to downloadable Word document | Both FAQ endpoints take JSON input: @@ -226,7 +244,12 @@ Response format: ```json { "items": [ - {"question": "What services are included?", "answer": "Website monitoring and software maintenance."} + { + "question": "What services are included?", + "answer": "Website monitoring and software maintenance.", + "source": {"section": "Services", "startLine": 1, "endLine": 2, + "excerpt": "## Services\nWebsite monitoring and software maintenance."} + } ], "count": 1, "notice": "..." @@ -235,6 +258,24 @@ Response format: The `/api/faqs/preview` endpoint deliberately returns zero results if there are no complete, explicit Q&A pairs. The `/api/faqs/generate` endpoint can produce a conservative draft from ordinary text, even without headings. +Export endpoints accept this shape (1–30 nonblank entries; references are optional): + +```json +{ + "title": "Team FAQ", + "includeSources": true, + "filename": "team-Doc2FAQ.pdf", + "items": [{ + "question": "Where is support?", + "answer": "Ask in the team channel.", + "edited": false, + "sources": [{"section": "Support", "startLine": 1, "endLine": 2}] + }] +} +``` + +Export references are supplied by the caller. They identify passages for human review; the export API does not independently verify their origin. Both download responses use `Cache-Control: no-store`. The optional `filename` controls the attachment name; the endpoint enforces the PDF/DOCX extension. Exports omit the FAQ-count subtitle. + ## Tests ```bash @@ -242,7 +283,7 @@ mvn -B -ntp clean verify node --test src/test/js/*.test.cjs ``` -GitHub Actions automatically runs the Java and JavaScript checks on pushes and pull requests. Tests cover both modes, whitespace-heavy parsing, stale responses and errors, distinct client/provider topics, FAQ appendix/title exclusions, document import errors, API validation, output limits, and export safety. The frontend tests require Node.js and have no npm dependencies. +GitHub Actions automatically runs the Java and JavaScript checks on pushes and pull requests. Tests cover both modes, exact source references, preservation of distinct passages, stale responses, document import errors, project round trips and invalid files, merge behavior, API validation, Unicode exports, PDF pagination, and export safety. The frontend tests require Node.js and have no npm dependencies. ## Troubleshooting @@ -258,7 +299,7 @@ GitHub Actions automatically runs the Java and JavaScript checks on pushes and p ## Security and privacy -Doc2FAQ currently has **no accounts, billing, rate limiting, or permanent draft storage**. PDF/DOCX files are processed for the request and are not intentionally saved by the app, though the web server may use temporary multipart buffering. TXT/Markdown are read in the browser and submitted as text when you generate a draft. Editing and exporting happen in the browser; drafts disappear when the page is reloaded. +Doc2FAQ currently has **no accounts, billing, rate limiting, or server-side draft storage**. PDF/DOCX files are processed for the request and are not intentionally saved by the app, though the web server may use temporary multipart buffering. TXT/Markdown are read in the browser and submitted as text when you generate a draft. Editing, project saves, and text-format exports happen in the browser. PDF/DOCX export sends the edited FAQ content and reference labels to this server for an in-memory download. With autosave off, unsaved drafts disappear on reload. Downloaded project files and optional browser autosave contain source text; clear saved data on shared devices when finished. This is a **local development application**, not a hardened internet-facing service. Before offering paid or public document processing, add authentication, rate limiting, isolated document parsing, malware scanning, HTTPS, audit/monitoring, and retention policies. Do not process confidential material on an untrusted server. @@ -266,6 +307,6 @@ This is a **local development application**, not a hardened internet-facing serv MIT · Copyright © 2026 **Okan Tiktas**. -## Planned enhancements +## Later releases -- PDF and DOCX FAQ export with page layout, fonts, document metadata, and validation tests (not included in this version). +Optional AI generation with explicit opt-in and token/cost limits, OCR, and account/subscription workflows are outside the v0.4.0 development scope. See [docs/RELEASING.md](docs/RELEASING.md) for the validation and release process. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index bd7c15d..f8029ea 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,56 +1,52 @@ # Doc2FAQ release checklist -This is the procedure for publishing **v0.3.1** from the protected `main` branch. Preparing a PR, merging fixes, and changing the Maven version do **not** replace an existing release JAR or publish a new GitHub Release. +The development branch targets **v0.4.0** and uses `0.4.0-SNAPSHOT` until release preparation is complete. A development PR does not publish a new Release or replace the v0.3.1 JAR. -## Before publishing +## Validate the development build -1. Merge the v0.3.1 release PR into `main` after the `build-and-test` check passes. -2. Update a clean local checkout to the merged release commit: +Use JDK 21, Maven 3.6.3+, and Node.js: + +```cmd +mvn -B -ntp clean verify +node --test src/test/js/*.test.cjs +java -jar target/doc2faq-0.4.0-SNAPSHOT.jar +``` + +Open `http://localhost:8080` (or use `--server.port=8081` if occupied). Check the packaged JAR with these steps: + +1. Extract explicit Markdown and Q:/A: questions. Expand Show source; verify exact excerpts and text-line ranges. +2. Generate from multiple sections, including repeated topics with different details. Confirm distinct passages remain. Use Keep both, merge, and Undo; confirm both references survive a merge. +3. Edit a question/answer; confirm its edited label. Remove an item and Undo. Reorder items and use search. +4. Set a document title, use Save work, refresh, and reopen it. Confirm source, order, edited values, references, and review choices. Save and reopen an incomplete FAQ too. An invalid project must leave the workspace intact. +5. Enable autosave, edit, wait for Saved in this browser, and reload. Confirm restore. Clear saved data and reload; confirm it is gone. Test with browser storage unavailable and use Save work as the fallback. +6. Preview exports and download Markdown, JSON, HTML, PDF, and DOCX with edited content. Upload `TEst Data.pdf` and confirm the suggested filename is `testdata-Doc2FAQ.pdf`; rename it and check the actual download. Clear document, reload, and confirm the previous PDF and draft stay cleared. Check centered titles, Lato PDF fonts, no FAQ-count subtitle, and optional references; HTML must display document markup as text. +7. Open PDF and Word downloads with short and long answers, Unicode text, long unbroken tokens, and page breaks. Check margins, heading placement, footer page numbers, and the final answer. Unsupported PDF glyphs must produce an error with the DOCX option. +8. Change source/mode or open a project while a draft/file request is pending. The old response must not replace current work. Check desktop and narrow-screen controls and keyboard focus. Move the pointer to check the background; enable reduced motion and confirm it stays still. + +## Prepare the release commit + +1. Review and merge the development PR after CI and the checks above pass. +2. Change the project version from `0.4.0-SNAPSHOT` to `0.4.0`, mark the changelog section released, and update README run commands. Review that release-preparation change. +3. Update a clean local checkout to the merged release commit: ```cmd git switch main git pull --ff-only origin main git status --short git rev-parse HEAD - ``` - - Confirm `git status --short` is empty. Record the commit SHA; the release tag and attached JAR must correspond to this commit. Verify the project version in `pom.xml` is exactly `0.3.1` (not `0.3.1-SNAPSHOT`). -3. Build and test with JDK 21, Maven 3.6.3+, and Node.js: - - ```cmd mvn -B -ntp clean verify node --test src/test/js/*.test.cjs + java -jar target/doc2faq-0.4.0.jar ``` - Continue only when both checks pass. The runnable artifact is **`target/doc2faq-0.3.1.jar`**; do not upload the `.jar.original` file or rename an older JAR. -4. Stop any previous Doc2FAQ instance, then smoke-test the actual packaged artifact: - - ```cmd - java -jar target/doc2faq-0.3.1.jar - ``` - - Confirm the startup log identifies **v0.3.1**. Open `http://localhost:8080` and refresh the browser. If another application owns port 8080, use `--server.port=8081` and open `http://localhost:8081` instead. Keep this terminal open while testing; use **Ctrl+C** to stop the app. - - - Extract Mode: `Q: Does this run locally?` followed by `A: Yes, without an external AI service.` on the next line produces one FAQ. - - Generate Mode: separate `## Client Responsibilities` and `## Provider Responsibilities` sections, each with a source paragraph, produce separate client and provider FAQs. - - Review: edit, reorder, remove, and add FAQ entries. - - Export: Markdown, JSON, and HTML downloads contain the expected edited questions and answers; HTML escapes document markup. -5. Confirm the repository has no existing **v0.3.1** tag or Release. Keep the existing v0.3.0 tag, Release, and JAR unchanged. +4. Confirm the working tree is clean and record the exact commit SHA. Repeat the packaged-JAR smoke check. Confirm no existing v0.4.0 tag or Release exists. -## Publish using GitHub +## Publish when ready 1. Open [Draft a new release](https://github.com/OKAN-SOFTWARE/Doc2FAQ/releases/new). -2. Choose **Create new tag**, enter **`v0.3.1`**, and target the recorded release commit SHA. If selecting `main`, verify its current SHA still matches the commit used to build the JAR. -3. Use release title: **Doc2FAQ v0.3.1 — Parsing and Draft Reliability Fixes**. -4. Copy the **0.3.1** section of [CHANGELOG.md](../CHANGELOG.md) into the Release notes. State that the fixes cover whitespace-heavy parsing, stale draft responses, and distinct client/provider responsibilities. Public-server hardening remains future work. -5. Attach the tested **`target/doc2faq-0.3.1.jar`**. GitHub automatically supplies source-code archives; those archives do not replace the runnable JAR. -6. Review the tag, commit, notes, and attached filename, then select **Publish release** when ready. - -## Verify after publishing - -- Confirm [Releases](https://github.com/OKAN-SOFTWARE/Doc2FAQ/releases) includes **v0.3.1**, its tag points to the recorded release commit, and the downloadable asset is **`doc2faq-0.3.1.jar`**. -- Verify the GitHub Actions check for the tag, if triggered, completes successfully. -- Download the attached JAR to a separate folder, run it with Java 21, and confirm the startup version, health endpoint, and client/provider generation behavior. -- Leave subscriptions, accounts, PDF/DOCX exports, and public-hosting hardening for separate changes. +2. Create tag `v0.4.0` at the exact recorded commit; title: **Doc2FAQ v0.4.0 — Review, Save & Export**. +3. Copy the 0.4.0 changelog section into the notes. Attach the tested `target/doc2faq-0.4.0.jar`, never `.jar.original` or a renamed older JAR. +4. Review the tag, commit, notes, and asset before publishing. Keep existing release artifacts unchanged. +5. Download the published JAR to a separate folder, run it with Java 21, and verify the version, health, project restore, source review, and PDF/Word downloads. -**License:** MIT · Copyright © 2026 Okan Tiktas. +Accounts, billing, OCR, optional external AI, and public-server hardening remain separate work. This build is intended for local use. diff --git a/pom.xml b/pom.xml index c501070..9a6d179 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.okansoftware doc2faq - 0.3.1 + 0.4.0-SNAPSHOT Doc2FAQ Privacy-conscious document-to-FAQ preview workbench diff --git a/src/main/java/com/okansoftware/doc2faq/api/ApiExceptionHandler.java b/src/main/java/com/okansoftware/doc2faq/api/ApiExceptionHandler.java index 8d73ae9..d58603d 100644 --- a/src/main/java/com/okansoftware/doc2faq/api/ApiExceptionHandler.java +++ b/src/main/java/com/okansoftware/doc2faq/api/ApiExceptionHandler.java @@ -25,7 +25,7 @@ public ResponseEntity validation(MethodArgumentNotValidException excep @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity malformedJson() { return ResponseEntity.badRequest() - .body(new ApiError("INVALID_JSON", "Provide a JSON object containing document text")); + .body(new ApiError("INVALID_JSON", "Provide a valid JSON object for this request")); } @ExceptionHandler(DocumentException.class) diff --git a/src/main/java/com/okansoftware/doc2faq/api/ExportController.java b/src/main/java/com/okansoftware/doc2faq/api/ExportController.java new file mode 100644 index 0000000..1f774a3 --- /dev/null +++ b/src/main/java/com/okansoftware/doc2faq/api/ExportController.java @@ -0,0 +1,49 @@ +package com.okansoftware.doc2faq.api; + +import com.okansoftware.doc2faq.model.ExportRequest; +import com.okansoftware.doc2faq.service.FaqExportService; +import jakarta.validation.Valid; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/faqs/export") +public class ExportController { + private final FaqExportService exporter; + + public ExportController(FaqExportService exporter) { + this.exporter = exporter; + } + + @PostMapping("/{format}") + public ResponseEntity export(@PathVariable String format, + @Valid @RequestBody ExportRequest request) throws IOException { + String mime = switch (format) { + case "pdf" -> "application/pdf"; + case "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + default -> throw new IllegalArgumentException("Choose PDF or DOCX export"); + }; + byte[] data = format.equals("pdf") ? exporter.pdf(request) : exporter.docx(request); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(mime)) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(downloadFilename(request.filename(), format), StandardCharsets.UTF_8).build().toString()) + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .body(data); + } + + private static String downloadFilename(String requested, String format) { + if (requested == null || requested.isBlank()) return "doc2faq-draft." + format; + String stem = requested.strip().replaceFirst("(?i)\\.(pdf|docx)$", "") + .replaceAll("[<>:\"/\\\\|?*\\p{Cntrl}]", "-") + .replaceAll("^\\.+|[. ]+$", ""); + if (stem.isBlank()) stem = "document-Doc2FAQ"; + if (stem.matches("(?i)^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\\..*)?$")) stem = "document-" + stem; + return stem + "." + format; + } +} diff --git a/src/main/java/com/okansoftware/doc2faq/model/ExportRequest.java b/src/main/java/com/okansoftware/doc2faq/model/ExportRequest.java new file mode 100644 index 0000000..a67aa23 --- /dev/null +++ b/src/main/java/com/okansoftware/doc2faq/model/ExportRequest.java @@ -0,0 +1,32 @@ +package com.okansoftware.doc2faq.model; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record ExportRequest( + @NotBlank @Size(max = 200) String title, + @NotEmpty @Size(max = 30) List<@NotNull @Valid Entry> items, + Boolean includeSources, + @Size(max = 200) String filename) { + public ExportRequest { + includeSources = Boolean.TRUE.equals(includeSources); + } + public record Entry( + @NotBlank @Size(max = 500) String question, + @NotBlank @Size(max = 50000) String answer, + @Size(max = 30) List<@NotNull @Valid Reference> sources, + Boolean edited) { + public Entry { + edited = Boolean.TRUE.equals(edited); + } + } + + public record Reference( + @NotBlank @Size(max = 50000) String section, + @Min(1) int startLine, @Min(1) int endLine) {} +} diff --git a/src/main/java/com/okansoftware/doc2faq/model/FaqItem.java b/src/main/java/com/okansoftware/doc2faq/model/FaqItem.java index 884d54b..a54cae4 100644 --- a/src/main/java/com/okansoftware/doc2faq/model/FaqItem.java +++ b/src/main/java/com/okansoftware/doc2faq/model/FaqItem.java @@ -1,3 +1,7 @@ package com.okansoftware.doc2faq.model; -public record FaqItem(String question, String answer) {} +public record FaqItem(String question, String answer, SourceReference source) { + public FaqItem(String question, String answer) { + this(question, answer, null); + } +} diff --git a/src/main/java/com/okansoftware/doc2faq/model/SourceReference.java b/src/main/java/com/okansoftware/doc2faq/model/SourceReference.java new file mode 100644 index 0000000..8cc10cc --- /dev/null +++ b/src/main/java/com/okansoftware/doc2faq/model/SourceReference.java @@ -0,0 +1,4 @@ +package com.okansoftware.doc2faq.model; + +/** Lines refer to the extracted/pasted text, not the original PDF's page layout. */ +public record SourceReference(String section, int startLine, int endLine, String excerpt) {} diff --git a/src/main/java/com/okansoftware/doc2faq/service/FaqDraftService.java b/src/main/java/com/okansoftware/doc2faq/service/FaqDraftService.java index 7b7c5e9..83a8857 100644 --- a/src/main/java/com/okansoftware/doc2faq/service/FaqDraftService.java +++ b/src/main/java/com/okansoftware/doc2faq/service/FaqDraftService.java @@ -2,6 +2,7 @@ import com.okansoftware.doc2faq.model.FaqItem; import com.okansoftware.doc2faq.model.PreviewResponse; +import com.okansoftware.doc2faq.model.SourceReference; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -39,20 +40,26 @@ public PreviewResponse preview(String content) { Set seen = new HashSet<>(); String question = null; StringBuilder answer = new StringBuilder(); + String[] lines = content.split("\\R", -1); + int start = 0; + int end = 0; - for (String rawLine : content.split("\\R", -1)) { + for (int i = 0; i < lines.length; i++) { + String rawLine = lines[i]; String line = rawLine.strip(); String nextQuestion = questionFrom(line); if (nextQuestion != null) { - addIfValid(items, seen, question, answer); + addIfValid(items, seen, question, answer, lines, start, end); question = nextQuestion; + start = i; + end = i; answer.setLength(0); continue; } // A new unrelated section ends the previous answer. if (line.startsWith("#")) { - addIfValid(items, seen, question, answer); + addIfValid(items, seen, question, answer, lines, start, end); question = null; answer.setLength(0); continue; @@ -67,10 +74,11 @@ public PreviewResponse preview(String content) { answer.append(' '); } answer.append(answerLine); + end = i; } } - addIfValid(items, seen, question, answer); + addIfValid(items, seen, question, answer, lines, start, end); return new PreviewResponse(List.copyOf(items), items.size(), NOTICE); } @@ -85,12 +93,15 @@ private static String questionFrom(String line) { } private static void addIfValid(List items, Set seen, - String question, StringBuilder answer) { + String question, StringBuilder answer, String[] lines, int start, int end) { if (question == null || answer.isEmpty() || items.size() >= MAX_ITEMS) { return; } - if (seen.add(question.toLowerCase(Locale.ROOT))) { - items.add(new FaqItem(question, answer.toString())); + String key = question.toLowerCase(Locale.ROOT) + "\n" + answer.toString().toLowerCase(Locale.ROOT); + if (seen.add(key)) { + String excerpt = String.join("\n", java.util.Arrays.copyOfRange(lines, start, end + 1)); + items.add(new FaqItem(question, answer.toString(), + new SourceReference(question, start + 1, end + 1, excerpt))); } } } diff --git a/src/main/java/com/okansoftware/doc2faq/service/FaqExportService.java b/src/main/java/com/okansoftware/doc2faq/service/FaqExportService.java new file mode 100644 index 0000000..cd39492 --- /dev/null +++ b/src/main/java/com/okansoftware/doc2faq/service/FaqExportService.java @@ -0,0 +1,263 @@ +package com.okansoftware.doc2faq.service; + +import com.okansoftware.doc2faq.model.ExportRequest; +import java.awt.Color; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import org.apache.pdfbox.pdmodel.*; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.poi.xwpf.usermodel.*; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.*; +import org.springframework.stereotype.Service; + +/** Stateless exports; content is written to an in-memory response, never to a server file. */ +@Service +public class FaqExportService { + private static void validate(ExportRequest request) { + validateText(request.title()); + int characters = request.title().length(); + for (var item : request.items()) { + validateText(item.question()); validateText(item.answer()); + characters += item.question().length() + item.answer().length(); + if (item.sources() != null) for (var source : item.sources()) { + validateText(source.section()); + if (source.endLine() < source.startLine() || source.endLine() > 50001) { + throw new IllegalArgumentException("Invalid source line range"); + } + characters += source.section().length(); + } + } + if (characters > 300000) throw new IllegalArgumentException("Export text must not exceed 300,000 characters"); + } + + private static void validateText(String text) { + if (text.codePoints().anyMatch(point -> (point < 32 && point != '\n' && point != '\r' && point != '\t') + || (point >= 0xD800 && point <= 0xDFFF) || point == 0xFFFE || point == 0xFFFF)) { + throw new IllegalArgumentException("Export text contains unsupported control characters. Remove them and retry."); + } + } + + public byte[] pdf(ExportRequest request) throws IOException { + validate(request); + try (PDDocument document = new PDDocument(); ByteArrayOutputStream output = new ByteArrayOutputStream(); + InputStream normal = font("Lato-Regular.ttf"); InputStream strong = font("Lato-Bold.ttf")) { + PDType0Font regular = PDType0Font.load(document, normal); + PDType0Font bold = PDType0Font.load(document, strong); + document.getDocumentInformation().setTitle(request.title().strip()); + document.getDocumentInformation().setCreator("Doc2FAQ"); + PdfLayout layout = new PdfLayout(document, regular, bold); + try { + layout.title(request.title().strip()); + for (int i = 0; i < request.items().size(); i++) { + var item = request.items().get(i); + layout.heading((i + 1) + ". " + item.question().strip()); + layout.paragraph(item.answer().strip(), 11, false, 10); + if (request.includeSources()) { + for (String reference : references(item)) layout.paragraph(reference, 9, false, 5); + } + layout.gap(12); + } + layout.finish(); + } finally { + layout.close(); + } + document.save(output); + return output.toByteArray(); + } catch (IllegalArgumentException exception) { + if (exception.getMessage() != null && (exception.getMessage().contains("glyph") + || exception.getMessage().contains("is not available"))) { + throw new IllegalArgumentException("PDF export encountered an unsupported character. Use DOCX export for this text."); + } + throw exception; + } + } + + private static InputStream font(String name) throws IOException { + InputStream input = FaqExportService.class.getResourceAsStream("/fonts/" + name); + if (input == null) throw new IOException("Bundled PDF font is missing"); + return input; + } + + public byte[] docx(ExportRequest request) throws IOException { + validate(request); + try (XWPFDocument document = new XWPFDocument(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + document.getProperties().getCoreProperties().setTitle(request.title().strip()); + document.getProperties().getCoreProperties().setCreator("Doc2FAQ"); + var styles = document.createStyles(); + addStyle(styles, "Title", "Title", 48, false); + addStyle(styles, "Heading1", "Heading 1", 28, true); + var section = document.getDocument().getBody().addNewSectPr(); + var page = section.addNewPgSz(); + page.setW(BigInteger.valueOf(12240)); + page.setH(BigInteger.valueOf(15840)); + var margins = section.addNewPgMar(); + margins.setTop(BigInteger.valueOf(1080)); margins.setBottom(BigInteger.valueOf(1080)); + margins.setLeft(BigInteger.valueOf(1080)); margins.setRight(BigInteger.valueOf(1080)); + + var title = paragraph(document, request.title().strip(), 24, false); + title.setStyle("Title"); + title.setAlignment(ParagraphAlignment.CENTER); + title.setSpacingAfter(360); + for (int i = 0; i < request.items().size(); i++) { + var item = request.items().get(i); + var heading = paragraph(document, (i + 1) + ". " + item.question().strip(), 14, true); + heading.setStyle("Heading1"); + heading.setKeepNext(true); + heading.setSpacingBefore(240); + paragraph(document, item.answer().strip(), 11, false); + if (request.includeSources()) { + for (String reference : references(item)) paragraph(document, reference, 9, false); + } + } + XWPFFooter footer = document.createFooter(org.apache.poi.wp.usermodel.HeaderFooterType.DEFAULT); + XWPFParagraph foot = footer.createParagraph(); + foot.setAlignment(ParagraphAlignment.RIGHT); + var run = foot.createRun(); run.setText("Doc2FAQ | Page "); run.setFontSize(9); + foot.getCTP().addNewFldSimple().setInstr("PAGE"); + document.write(output); + return output.toByteArray(); + } + } + + private static void addStyle(XWPFStyles styles, String id, String name, int halfPoints, boolean bold) { + CTStyle style = CTStyle.Factory.newInstance(); + style.setStyleId(id); style.setType(STStyleType.PARAGRAPH); + style.addNewName().setVal(name); + var properties = style.addNewRPr(); + properties.addNewSz().setVal(BigInteger.valueOf(halfPoints)); + properties.addNewColor().setVal("000000"); + if (bold) properties.addNewB(); + styles.addStyle(new XWPFStyle(style)); + } + + private static XWPFParagraph paragraph(XWPFDocument document, String text, int size, boolean bold) { + XWPFParagraph paragraph = document.createParagraph(); + paragraph.setSpacingAfter(160); + paragraph.setSpacingBetween(1.2); + XWPFRun run = paragraph.createRun(); + run.setFontFamily("Calibri"); run.setFontSize(size); run.setBold(bold); run.setColor("000000"); + String[] lines = text.split("\\R", -1); + for (int i = 0; i < lines.length; i++) { + if (i > 0) run.addBreak(); + run.setText(lines[i]); + } + return paragraph; + } + + private static List references(ExportRequest.Entry item) { + List result = new ArrayList<>(); + if (item.sources() != null) for (var source : item.sources()) { + result.add("Source: " + source.section() + " (text lines " + source.startLine() + "-" + source.endLine() + ")."); + } + if (result.isEmpty()) result.add("Manually added: no source reference."); + if (item.edited()) result.add("Edited answer or question: recheck against the source."); + return result; + } + + private static final class PdfLayout implements AutoCloseable { + private static final float MARGIN = 54; + private static final float WIDTH = PDRectangle.LETTER.getWidth() - 2 * MARGIN; + private final PDDocument document; + private final PDType0Font regular; + private final PDType0Font bold; + private PDPageContentStream stream; + private float y; + + PdfLayout(PDDocument document, PDType0Font regular, PDType0Font bold) throws IOException { + this.document = document; this.regular = regular; this.bold = bold; + newPage(); + } + + void newPage() throws IOException { + close(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + stream = new PDPageContentStream(document, page); + y = PDRectangle.LETTER.getHeight() - MARGIN; + } + + void heading(String text) throws IOException { + var lines = wrap(text, bold, 13); + if (y - (lines.size() * 19 + 32) < MARGIN && y < 738) newPage(); + paragraph(text, 13, true, 8); + } + + void title(String text) throws IOException { + int size = 26; + for (String line : wrap(text, bold, size)) { + if (y - size * 1.5f < MARGIN) newPage(); + float width = bold.getStringWidth(line) / 1000 * size; + draw(stream, line, bold, size, MARGIN + (WIDTH - width) / 2, y - size); + y -= size * 1.5f; + } + gap(24); + } + + void paragraph(String text, int size, boolean strong, int after) throws IOException { + PDType0Font font = strong ? bold : regular; + for (String line : wrap(text, font, size)) { + if (y - size * 1.5f < MARGIN) newPage(); + draw(stream, line, font, size, MARGIN, y - size); + y -= size * 1.5f; + } + gap(after); + } + + private static List wrap(String text, PDType0Font font, int size) throws IOException { + List lines = new ArrayList<>(); + for (String paragraph : text.replace('\t', ' ').split("\\R", -1)) { + StringBuilder line = new StringBuilder(); + float width = 0; + int lastSpace = -1; + for (int offset = 0; offset < paragraph.length();) { + int point = paragraph.codePointAt(offset); + String character = new String(Character.toChars(point)); + float advance = font.getStringWidth(character) / 1000 * size; + while (width + advance > WIDTH && !line.isEmpty()) { + if (lastSpace > 0) { + lines.add(line.substring(0, lastSpace)); + String rest = line.substring(lastSpace + 1); + line.setLength(0); line.append(rest); + width = font.getStringWidth(rest) / 1000 * size; + } else { + lines.add(line.toString()); line.setLength(0); width = 0; + } + lastSpace = line.lastIndexOf(" "); + } + if (point == ' ') lastSpace = line.length(); + line.append(character); width += advance; + offset += Character.charCount(point); + } + lines.add(line.toString()); + } + return lines; + } + + void gap(int points) { y -= points; } + + void finish() throws IOException { + close(); + for (int i = 0; i < document.getNumberOfPages(); i++) { + try (var footer = new PDPageContentStream(document, document.getPage(i), + PDPageContentStream.AppendMode.APPEND, true, true)) { + draw(footer, "Doc2FAQ | " + (i + 1) + " / " + document.getNumberOfPages(), regular, 9, MARGIN, 30); + } + } + } + + private static void draw(PDPageContentStream target, String text, PDType0Font font, + int size, float x, float y) throws IOException { + target.beginText(); target.setFont(font, size); target.setNonStrokingColor(Color.BLACK); + target.newLineAtOffset(x, y); target.showText(text); target.endText(); + } + + @Override public void close() throws IOException { + if (stream != null) { stream.close(); stream = null; } + } + } +} diff --git a/src/main/java/com/okansoftware/doc2faq/service/FaqGenerateService.java b/src/main/java/com/okansoftware/doc2faq/service/FaqGenerateService.java index 88447db..d7d5819 100644 --- a/src/main/java/com/okansoftware/doc2faq/service/FaqGenerateService.java +++ b/src/main/java/com/okansoftware/doc2faq/service/FaqGenerateService.java @@ -2,8 +2,8 @@ import com.okansoftware.doc2faq.model.FaqItem; import com.okansoftware.doc2faq.model.PreviewResponse; +import com.okansoftware.doc2faq.model.SourceReference; import java.util.ArrayList; -import java.util.EnumSet; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -26,7 +26,7 @@ public class FaqGenerateService { private static final Pattern ALL_CAPS_HEADING = Pattern.compile("^[\\p{Lu}][\\p{Lu}\\p{N} /&(),.'–-]{2,79}:?$"); private static final Pattern TITLE_CASE_HEADING = Pattern.compile("^[\\p{Lu}][\\p{L}\\p{N} /&(),.'–-]{2,59}:?$"); private static final String NOTICE = "Generated locally from source passages using deterministic rules. " - + "Common duplicate topics and FAQ appendices are filtered. No AI service is used. " + + "Exact duplicate passages and FAQ appendices are filtered; similar questions remain for review. No AI service is used. " + "Verify and edit every question and answer before sharing."; private final FaqDraftService extractor; @@ -44,12 +44,9 @@ public PreviewResponse generate(String content) { } List items = new ArrayList<>(extractor.preview(content).items()); - Set seen = new HashSet<>(); - Set seenTopics = EnumSet.noneOf(Topic.class); + Set seenPassages = new HashSet<>(); for (FaqItem item : items) { - seen.add(item.question().toLowerCase(Locale.ROOT)); - Topic topic = topicForQuestion(item.question()); - if (topic != Topic.OTHER) seenTopics.add(topic); + seenPassages.add(item.question().toLowerCase(Locale.ROOT) + "\n" + item.answer().strip().toLowerCase(Locale.ROOT)); } for (Section section : splitSections(content)) { @@ -61,43 +58,47 @@ public PreviewResponse generate(String content) { || isBoilerplateHeading(section.heading)) { continue; } - Topic topic = topicForHeading(section.heading); - // Preserve all explicit pairs, but do not propose the same subject again. - if (topic != Topic.OTHER && seenTopics.contains(topic)) { - continue; - } - String question = questionFor(section.heading); - if (seen.add(question.toLowerCase(Locale.ROOT))) { - items.add(new FaqItem(question, excerpt(passage))); - if (topic != Topic.OTHER) seenTopics.add(topic); + String answer = excerpt(passage); + if (seenPassages.add(questionFor(section.heading).toLowerCase(Locale.ROOT) + "\n" + passage.toLowerCase(Locale.ROOT))) { + items.add(new FaqItem(questionFor(section.heading), answer, + new SourceReference(section.heading, section.startLine, section.endLine, + section.original.substring(0, section.original.length() - 1)))); } } // Conservative fallback for documents without recognizable sections. if (items.isEmpty()) { String passage = content.strip().replaceAll("\\s+", " "); - items.add(new FaqItem("What information does this document provide?", excerpt(passage))); + items.add(new FaqItem("What information does this document provide?", excerpt(passage), + new SourceReference("Document", 1, content.split("\\R", -1).length, + String.join("\n", content.split("\\R", -1))))); } return new PreviewResponse(List.copyOf(items), items.size(), NOTICE); } private static List
splitSections(String content) { List
sections = new ArrayList<>(); - Section current = new Section("Overview"); - for (String raw : content.split("\\R", -1)) { + Section current = new Section("Overview", 1); + String[] lines = content.split("\\R", -1); + for (int i = 0; i < lines.length; i++) { + String raw = lines[i]; String line = raw.strip(); if (line.isBlank()) { if (!current.body.isEmpty()) current.body.append(' '); + current.original.append(raw).append('\n'); + current.endLine = i + 1; continue; } String heading = headingOf(line); if (heading != null) { if (!current.body.toString().isBlank()) sections.add(current); - current = new Section(heading); + current = new Section(heading, i + 1); } else { if (!current.body.isEmpty()) current.body.append(' '); current.body.append(line); } + current.original.append(raw).append('\n'); + current.endLine = i + 1; } if (!current.body.toString().isBlank()) sections.add(current); return sections; @@ -151,57 +152,6 @@ private static boolean isBoilerplateHeading(String heading) { || (h.endsWith(" contract") && !h.contains("term")); } - private static Topic topicForHeading(String heading) { - String h = heading.toLowerCase(Locale.ROOT); - if (h.contains("terminat") || h.contains("cancellat")) return Topic.TERMINATION; - if (h.contains("payment") || h.contains("invoic")) return Topic.PAYMENT; - if (h.contains("fee") || h.contains("pricing") || h.contains("cost")) return Topic.FEES; - if (h.contains("responsibilit") || h.contains("obligation")) return responsibilityTopic(h); - if (h.contains("ownership") || h.contains("intellectual property")) return Topic.OWNERSHIP; - if (h.contains("confidential")) return Topic.CONFIDENTIALITY; - if (h.contains("scope") || h.contains("service")) return Topic.SERVICES; - if (h.equals("term") || h.contains("duration") || h.contains("agreement term") - || h.contains("effective date")) return Topic.TERM; - if (h.contains("summary")) return Topic.SUMMARY; - if (h.contains("skill") || h.contains("technolog")) return Topic.SKILLS; - if (h.contains("experience")) return Topic.EXPERIENCE; - if (h.contains("education")) return Topic.EDUCATION; - return Topic.OTHER; - } - - /** Uses recognizable question intent, not exact string equality. */ - private static Topic topicForQuestion(String question) { - String q = question.toLowerCase(Locale.ROOT); - if (q.contains("terminat") || q.contains("cancel") - || q.contains("end the agreement")) return Topic.TERMINATION; - if (q.contains("when is payment") || q.contains("payment due") - || q.contains("invoice") || q.contains("how are payments")) return Topic.PAYMENT; - if (q.contains("fee") || q.contains("pricing") || q.contains("price") - || q.contains("cost")) return Topic.FEES; - if (q.contains("responsibilit") || q.contains("obligation")) return responsibilityTopic(q); - if (q.contains("ownership") || q.contains("owns the work") || q.contains("deliverables")) - return Topic.OWNERSHIP; - if (q.contains("confidential")) return Topic.CONFIDENTIALITY; - if (q.contains("agreement begin") || q.contains("engagement begin") - || q.contains("agreement last") || q.contains("effective date") - || q.contains("agreement term") || q.contains("duration")) return Topic.TERM; - if (q.contains("service") || q.contains("maintenance hours") - || q.contains("scope of work")) return Topic.SERVICES; - if (q.contains("professional background")) return Topic.SUMMARY; - if (q.contains("skill") || q.contains("technolog")) return Topic.SKILLS; - if (q.contains("experience")) return Topic.EXPERIENCE; - if (q.contains("education")) return Topic.EDUCATION; - return Topic.OTHER; - } - - private static Topic responsibilityTopic(String text) { - // Separate parties so an explicit or generated client FAQ cannot hide - // the provider's distinct obligations (and vice versa). - if (text.contains("client") && !text.contains("provider")) return Topic.CLIENT_RESPONSIBILITIES; - if (text.contains("provider") && !text.contains("client")) return Topic.PROVIDER_RESPONSIBILITIES; - return Topic.RESPONSIBILITIES; - } - private static String questionFor(String heading) { String h = heading.toLowerCase(Locale.ROOT); if (h.contains("termination") || h.contains("cancellation")) @@ -237,12 +187,6 @@ private static String questionFor(String heading) { return "What information is provided about " + heading + "?"; } - private enum Topic { - SERVICES, TERM, FEES, PAYMENT, TERMINATION, RESPONSIBILITIES, - CLIENT_RESPONSIBILITIES, PROVIDER_RESPONSIBILITIES, - OWNERSHIP, CONFIDENTIALITY, SUMMARY, SKILLS, EXPERIENCE, EDUCATION, OTHER - } - private static String excerpt(String passage) { if (passage.length() <= MAX_ANSWER_CHARS) return passage; int boundary = passage.lastIndexOf(' ', MAX_ANSWER_CHARS - 1); @@ -253,9 +197,14 @@ private static String excerpt(String passage) { private static final class Section { private final String heading; private final StringBuilder body = new StringBuilder(); + private final StringBuilder original = new StringBuilder(); + private final int startLine; + private int endLine; - private Section(String heading) { + private Section(String heading, int startLine) { this.heading = heading; + this.startLine = startLine; + this.endLine = startLine; } } } diff --git a/src/main/resources/fonts/LICENSE-Lato.txt b/src/main/resources/fonts/LICENSE-Lato.txt new file mode 100644 index 0000000..4d308fb --- /dev/null +++ b/src/main/resources/fonts/LICENSE-Lato.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/main/resources/fonts/Lato-Bold.ttf b/src/main/resources/fonts/Lato-Bold.ttf new file mode 100644 index 0000000..1d23c70 Binary files /dev/null and b/src/main/resources/fonts/Lato-Bold.ttf differ diff --git a/src/main/resources/fonts/Lato-Regular.ttf b/src/main/resources/fonts/Lato-Regular.ttf new file mode 100644 index 0000000..0f3d0f8 Binary files /dev/null and b/src/main/resources/fonts/Lato-Regular.ttf differ diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index 030779a..64f3af6 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -4,6 +4,9 @@ const MAX_CHARS = 50_000; const MAX_TEXT_FILE_BYTES = 256_000; const MAX_UPLOAD_FILE_BYTES = 5 * 1024 * 1024; const MAX_ITEMS = 30; +const projectTools = globalThis.Doc2FaqProject; +const SAVE_KEY = "doc2faq.project.v1"; +const AUTOSAVE_KEY = "doc2faq.autosave"; const extractExample = [ "## What is Doc2FAQ?", @@ -45,6 +48,73 @@ const addButton = $("add-item-btn"); const modeSelect = $("draft-mode"); let items = []; let draftRevision = 0; +let sourceName = "Pasted text"; +let undoHistory = []; +let saveTimer; +let importRevision = 0; +let reviewUpdaters = []; +let activeDraftRequest = 0; +let activeExportRequest = 0; +let exporting = false; + +function projectTitle() { return $("project-title").value.trim() || "FAQ draft"; } + +function currentProject() { + return {type: "doc2faq-project", schemaVersion: 1, title: projectTitle(), mode: selectedMode(), + downloadName: $("download-name").value, source: {text: source.value, name: sourceName}, items}; +} + +function autosave() { + if (!$("autosave-toggle").checked) return; + clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + try { + localStorage.setItem(SAVE_KEY, projectTools.serialize(currentProject())); + $("save-status").textContent = "Saved in this browser"; + } catch { + $("save-status").textContent = "Browser save unavailable. Download a project to keep your work."; + } + }, 250); +} + +function remember() { + draftRevision++; + undoHistory.push(JSON.stringify(items)); + if (undoHistory.length > 20) undoHistory.shift(); +} + +function applyProject(project) { + draftRevision++; + importRevision++; + activeDraftRequest++; + activeExportRequest++; + exporting = false; + draftButton.disabled = false; + $("file-input").disabled = false; + source.value = project.source.text; + sourceName = project.source.name; + $("project-title").value = project.title; + $("download-name").value = projectTools.downloadFilename(project.downloadName, "pdf").replace(/\.pdf$/, ""); + modeSelect.value = project.mode; + items = project.items; + undoHistory = []; + search.value = ""; + updateModeText(); updateCount(); render(); autosave(); +} + +function restoreSavedProject() { + try { + if (localStorage.getItem(AUTOSAVE_KEY) !== "true") return; + $("autosave-toggle").checked = true; + const saved = localStorage.getItem(SAVE_KEY); + if (saved) { + applyProject(projectTools.parse(saved)); + setStatus("Restored your saved project from this browser.", "success"); + } + } catch { + $("save-status").textContent = "Saved project could not be restored. Clear saved data or open a project file."; + } +} function setStatus(message, kind = "") { status.textContent = message; @@ -80,12 +150,17 @@ function updateControls() { const hasItems = items.length > 0; const ready = validDraft(); search.disabled = !hasItems; - copyButton.disabled = !ready; - downloadButton.disabled = !ready; + copyButton.disabled = !ready || ["pdf", "docx"].includes(formatSelect.value); + downloadButton.disabled = !ready || exporting; formatSelect.disabled = !hasItems; addButton.disabled = items.length >= MAX_ITEMS; - const ext = {markdown: "md", json: "json", html: "html"}[formatSelect.value] || "md"; + $("preview-btn").disabled = !ready; + $("undo-btn").disabled = !undoHistory.length; + const ext = {markdown: "md", json: "json", html: "html", pdf: "pdf", docx: "docx"}[formatSelect.value] || "md"; downloadButton.textContent = "Download ." + ext; + $("download-extension").textContent = "." + ext; + $("source-filename").textContent = source.value ? sourceName : "No document loaded"; + $("clear-document-btn").disabled = !source.value && !hasItems && !$("file-input").disabled; count.textContent = items.length + (items.length === 1 ? " ITEM" : " ITEMS"); } @@ -103,11 +178,14 @@ function move(item, offset) { const from = items.indexOf(item); const to = from + offset; if (from < 0 || to < 0 || to >= items.length) return; + draftRevision++; + undoHistory = []; [items[from], items[to]] = [items[to], items[from]]; - render(); + render(); autosave(); } function render() { + reviewUpdaters = []; const term = search.value.trim().toLocaleLowerCase(); const shown = items.filter((item) => (item.question + " " + item.answer).toLocaleLowerCase().includes(term)); @@ -147,10 +225,11 @@ function render() { makeButton("↑", "editor-action", () => move(item, -1), index === 0), makeButton("↓", "editor-action", () => move(item, 1), index === items.length - 1), makeButton("Remove", "editor-action editor-remove", () => { + remember(); const found = items.indexOf(item); if (found !== -1) items.splice(found, 1); - render(); - setStatus("FAQ item removed. Changes are in this browser session.", "success"); + render(); autosave(); + setStatus("FAQ removed. Use Undo to restore it.", "success"); }) ); heading.append(number, cardActions); @@ -166,8 +245,12 @@ function render() { question.maxLength = 500; question.value = item.question; question.addEventListener("input", () => { + draftRevision++; + undoHistory = []; item.question = question.value; - updateControls(); + updateControls(); updateBadge(); + for (const update of reviewUpdaters) update(); + autosave(); }); const answerLabel = document.createElement("label"); @@ -178,29 +261,100 @@ function render() { answer.id = answerLabel.htmlFor; answer.className = "faq-edit-answer"; answer.rows = 4; - answer.maxLength = 10_000; + answer.maxLength = 50_000; answer.value = item.answer; answer.addEventListener("input", () => { + draftRevision++; + undoHistory = []; item.answer = answer.value; - updateControls(); + updateControls(); updateBadge(); autosave(); }); - card.append(heading, questionLabel, question, answerLabel, answer); + const reviewDetails = document.createElement("div"); + const badge = document.createElement("p"); + badge.className = "source-badge"; + function updateBadge() { + badge.textContent = !item.sources.length ? "Manually added — no source reference" : + projectTools.edited(item) ? "Edited — recheck against the source" : "Source excerpt available"; + } + updateBadge(); + reviewDetails.append(badge); + for (const reference of item.sources) { + const details = document.createElement("details"); + details.className = "source-details"; + const summary = document.createElement("summary"); + summary.textContent = "Show source · " + reference.section + " · text lines " + reference.startLine + "–" + reference.endLine; + const excerpt = document.createElement("pre"); + excerpt.textContent = reference.excerpt; + details.append(summary, excerpt); + reviewDetails.append(details); + } + const similarDetails = document.createElement("div"); + reviewDetails.append(similarDetails); + function updateSimilarDetails() { + similarDetails.replaceChildren(); + const related = projectTools.related(items, item); + if (related.length) { + const review = document.createElement("details"); + review.className = "similar-review"; + const summary = document.createElement("summary"); + summary.textContent = related.length + " similar question(s) — review details"; + review.append(summary); + for (const other of related) { + const row = document.createElement("div"); + const label = document.createElement("p"); + label.textContent = "FAQ " + (items.indexOf(other) + 1) + ": " + other.question; + row.append(label, + makeButton("Keep both", "editor-action", () => { + draftRevision++; + undoHistory = []; + item.keptWith.push(other.id); other.keptWith.push(item.id); + render(); autosave(); + }), + makeButton("Merge into this FAQ", "editor-action", () => { + try { + const merged = projectTools.merge(item, other); + remember(); + items[items.indexOf(item)] = merged; + items.splice(items.indexOf(other), 1); + render(); autosave(); + setStatus("Answers and source references combined. Review the result; Undo restores both FAQs.", "success"); + } catch (error) { setStatus(error.message, "error"); } + }) + ); + review.append(row); + } + similarDetails.append(review); + } + } + reviewUpdaters.push(updateSimilarDetails); + updateSimilarDetails(); + card.append(heading, questionLabel, question, answerLabel, answer, reviewDetails); results.append(card); } } function markdown() { - return "# FAQ draft\n\n" + items.map((item) => - "## " + item.question.trim() + "\n\n" + item.answer.trim()).join("\n\n") + "\n"; + return "# " + projectTitle() + "\n\n" + items.map((item) => + "## " + item.question.trim() + "\n\n" + item.answer.trim() + + ($("include-sources").checked ? "\n\n" + referenceText(item) : "")).join("\n\n") + "\n"; +} + +function referenceText(item) { + const references = item.sources || []; + const text = references.map((ref) => "Source: " + ref.section + " (text lines " + ref.startLine + "–" + ref.endLine + ")."); + if (!text.length) text.push("Manually added — no source reference."); + if (projectTools.edited(item)) text.push("Edited question or answer — recheck against the source."); + return text.join("\n"); } function json() { return JSON.stringify({ - title: "FAQ draft", + title: projectTitle(), items: items.map((item) => ({ question: item.question.trim(), - answer: item.answer.trim() + answer: item.answer.trim(), + ...($("include-sources").checked ? {sources: item.sources || [], edited: projectTools.edited(item)} : {}) })) }, null, 2) + "\n"; } @@ -216,12 +370,14 @@ function html() { '
\n' + "

" + escapeHtml(item.question.trim()) + "

\n" + "

" + escapeHtml(item.answer.trim()).replace(/\n/g, "
") + "

\n" + + ($("include-sources").checked ? "

" + escapeHtml(referenceText(item)).replace(/\n/g, "
") + "

\n" : "") + "
").join("\n"); return '\n\n\n' + ' \n' + ' \n' + - " FAQ draft\n\n\n" + - "
\n

FAQ draft

\n" + entries + + " " + escapeHtml(projectTitle()) + "\n" + + " \n\n\n" + + "
\n

" + escapeHtml(projectTitle()) + "

\n" + entries + "\n
\n\n\n"; } @@ -233,27 +389,39 @@ function exportData() { function clearDraft() { draftRevision++; + activeDraftRequest++; + draftButton.disabled = false; + updateModeText(); items = []; + undoHistory = []; search.value = ""; render(); } source.addEventListener("input", () => { + importRevision++; + $("file-input").disabled = false; updateCount(); - clearDraft(); + clearDraft(); autosave(); + setStatus("Source updated. Create a new FAQ draft."); }); search.addEventListener("input", render); modeSelect.addEventListener("change", () => { updateModeText(); - clearDraft(); + clearDraft(); autosave(); setStatus("Mode changed. Create a new draft from your document."); }); formatSelect.addEventListener("change", updateControls); $("sample-btn").addEventListener("click", () => { source.value = selectedMode() === "generate" ? generateExample : extractExample; + importRevision++; + sourceName = "Example document"; + $("project-title").value = selectedMode() === "generate" ? "Team Knowledge FAQ" : "Doc2FAQ"; + $("download-name").value = "example-Doc2FAQ"; + $("file-input").disabled = false; updateCount(); - clearDraft(); + clearDraft(); autosave(); source.focus(); setStatus("Example loaded. Create your FAQ draft to preview the results.", "success"); }); @@ -261,8 +429,10 @@ $("sample-btn").addEventListener("click", () => { addButton.addEventListener("click", () => { if (items.length >= MAX_ITEMS) return; search.value = ""; - items.push({question: "", answer: ""}); - render(); + draftRevision++; + undoHistory = []; + items.push(projectTools.normalizeItem({question: "", answer: ""})); + render(); autosave(); const field = results.querySelector(".faq-editor:last-child .faq-edit-question"); field?.focus(); setStatus("New FAQ added. Complete both fields to enable export."); @@ -291,6 +461,9 @@ $("file-input").addEventListener("change", async (event) => { } input.disabled = true; + const revision = ++importRevision; + draftRevision++; + updateControls(); setStatus(isText ? "Reading text file…" : "Extracting document text on this server…"); try { let text; @@ -303,9 +476,11 @@ $("file-input").addEventListener("change", async (event) => { method: "POST", body }); const data = await response.json().catch(() => ({})); + if (revision !== importRevision) return; if (!response.ok) throw new Error(data.message || "Could not read this document."); text = data.text; } + if (revision !== importRevision) return; if (typeof text !== "string" || !text.trim()) { throw new Error("No usable text found. Scanned PDFs need OCR."); } @@ -313,13 +488,19 @@ $("file-input").addEventListener("change", async (event) => { throw new Error("Extracted text exceeds 50,000 characters."); } source.value = text; + sourceName = file.name.slice(0, 255); + $("project-title").value = projectTools.sourceTitle(sourceName); + $("download-name").value = projectTools.defaultDownloadName(sourceName); updateCount(); - clearDraft(); + clearDraft(); autosave(); setStatus("Loaded " + file.name + ". Review the source text, then create a draft.", "success"); } catch (error) { - setStatus(error.message || "Could not read the selected file.", "error"); + if (revision === importRevision) setStatus(error.message || "Could not read the selected file.", "error"); } finally { - input.disabled = false; + if (revision === importRevision) { + input.disabled = false; + updateControls(); + } } }); @@ -338,6 +519,7 @@ draftButton.addEventListener("click", async () => { const mode = selectedMode(); const generating = mode === "generate"; const revision = draftRevision; + const requestId = ++activeDraftRequest; const isCurrentDraft = () => revision === draftRevision && source.value === content && selectedMode() === mode; draftButton.disabled = true; @@ -355,10 +537,11 @@ draftButton.addEventListener("click", async () => { items = Array.isArray(data.items) ? data.items.filter((item) => item && typeof item.question === "string" && typeof item.answer === "string") - .slice(0, MAX_ITEMS).map((item) => ({question: item.question, answer: item.answer})) + .slice(0, MAX_ITEMS).map(projectTools.normalizeItem) : []; + undoHistory = []; search.value = ""; - render(); + render(); autosave(); setStatus(items.length ? "Created " + items.length + " FAQ item(s). Edit and verify before exporting." : "No complete Q&A pairs found. Switch to Generate Mode for ordinary documents.", @@ -368,8 +551,10 @@ draftButton.addEventListener("click", async () => { setStatus(error.message || "The FAQ service is unavailable.", "error"); } } finally { - draftButton.disabled = false; - updateModeText(); + if (requestId === activeDraftRequest) { + draftButton.disabled = false; + updateModeText(); + } } }); @@ -386,24 +571,153 @@ copyButton.addEventListener("click", async () => { } }); -downloadButton.addEventListener("click", () => { +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; link.download = filename; + document.body.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +downloadButton.addEventListener("click", async () => { if (!validDraft()) { setStatus("Complete all questions and answers before exporting.", "error"); return; } - const {text, ext, mime} = exportData(); - const blob = new Blob([text], {type: mime + ";charset=utf-8"}); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "doc2faq-draft." + ext; - document.body.append(link); - link.click(); - link.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - setStatus("Downloaded your " + ext.toUpperCase() + " FAQ draft.", "success"); + const format = formatSelect.value; + const filename = projectTools.downloadFilename($("download-name").value, format); + const revision = draftRevision; + const requestId = ++activeExportRequest; + exporting = true; + updateControls(); + try { + if (["pdf", "docx"].includes(format)) { + const response = await fetch("/api/faqs/export/" + format, { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({title: projectTitle(), filename, includeSources: $("include-sources").checked, + items: items.map((item) => ({question: item.question.trim(), answer: item.answer.trim(), + sources: item.sources.map(({section, startLine, endLine}) => ({section, startLine, endLine})), + edited: projectTools.edited(item)}))}) + }); + if (revision !== draftRevision) return; + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.message || "Export failed. Please try again."); + } + const blob = await response.blob(); + if (revision !== draftRevision) return; + downloadBlob(blob, filename); + } else { + const {text, mime} = exportData(); + downloadBlob(new Blob([text], {type: mime + ";charset=utf-8"}), filename); + } + setStatus("Downloaded your " + format.toUpperCase() + " FAQ draft.", "success"); + } catch (error) { + if (revision === draftRevision) setStatus(error.message || "Download unavailable.", "error"); + } finally { + if (requestId === activeExportRequest) { exporting = false; updateControls(); } + } +}); + +$("project-title").addEventListener("input", autosave); +$("download-name").addEventListener("input", autosave); +$("download-name").addEventListener("change", () => { + $("download-name").value = projectTools.downloadFilename($("download-name").value, formatSelect.value).replace(/\.[^.]+$/, ""); + autosave(); +}); +$("clear-document-btn").addEventListener("click", () => { + clearTimeout(saveTimer); + importRevision++; + activeExportRequest++; + exporting = false; + source.value = ""; + sourceName = "Pasted text"; + $("file-input").value = ""; + $("file-input").disabled = false; + $("project-input").value = ""; + $("project-title").value = "FAQ draft"; + $("download-name").value = "document-Doc2FAQ"; + $("include-sources").checked = false; + if ($("export-preview").open) $("export-preview").close(); + $("preview-content").replaceChildren(); + updateCount(); clearDraft(); + try { + localStorage.removeItem(SAVE_KEY); + $("save-status").textContent = $("autosave-toggle").checked ? "Autosave on · document cleared" : "Autosave off"; + setStatus("Document and its FAQ draft cleared. Choose a new file to start again.", "success"); + } catch { + setStatus("Document cleared. Browser storage is unavailable, so its saved copy could not be cleared.", "error"); + } +}); +$("open-project-btn").addEventListener("click", () => $("project-input").click()); +$("choose-file-btn").addEventListener("click", () => $("file-input").click()); +$("save-project-btn").addEventListener("click", () => { + try { + downloadBlob(new Blob([projectTools.serialize(currentProject())], {type: "application/json;charset=utf-8"}), "doc2faq-project.json"); + $("save-status").textContent = "Work saved"; + setStatus("Your source and edits are saved. Use Open saved work to continue later.", "success"); + } catch (error) { setStatus(error.message, "error"); } +}); +$("project-input").addEventListener("change", async (event) => { + const input = event.target; + const file = input.files?.[0]; + input.value = ""; + if (!file) return; + if (file.size > projectTools.MAX_PROJECT_BYTES) { setStatus("Project files must not exceed 4 MB.", "error"); return; } + const revision = ++importRevision; + try { + const project = projectTools.parse(await file.text()); + if (revision !== importRevision) return; + if ((source.value || items.length) && !globalThis.confirm("Open this project and replace the current workspace? Save your current project first if needed.")) return; + applyProject(project); + setStatus("Project opened. Your edits and references are ready.", "success"); + } catch (error) { if (revision === importRevision) setStatus(error.message, "error"); } +}); +$("autosave-toggle").addEventListener("change", () => { + try { + if ($("autosave-toggle").checked) { + localStorage.setItem(AUTOSAVE_KEY, "true"); autosave(); + } else { + clearTimeout(saveTimer); + localStorage.removeItem(AUTOSAVE_KEY); localStorage.removeItem(SAVE_KEY); + $("save-status").textContent = "Autosave off"; + } + } catch { $("save-status").textContent = "Browser storage unavailable. Use Save work."; } +}); +$("clear-saved-btn").addEventListener("click", () => { + clearTimeout(saveTimer); + try { + localStorage.removeItem(SAVE_KEY); localStorage.removeItem(AUTOSAVE_KEY); + $("autosave-toggle").checked = false; + $("save-status").textContent = "Saved data cleared; current workspace stays open"; + } catch { $("save-status").textContent = "Could not clear browser storage."; } +}); +$("undo-btn").addEventListener("click", () => { + if (!undoHistory.length) return; + draftRevision++; + items = JSON.parse(undoHistory.pop()); + render(); autosave(); + setStatus("Previous removal or merge undone.", "success"); +}); +$("preview-btn").addEventListener("click", () => { + if (!validDraft()) return; + const content = $("preview-content"); + content.replaceChildren(); + const title = document.createElement("h1"); title.textContent = projectTitle(); content.append(title); + for (const item of items) { + const heading = document.createElement("h2"); heading.textContent = item.question; + const answer = document.createElement("p"); answer.textContent = item.answer; + content.append(heading, answer); + if ($("include-sources").checked) { + const reference = document.createElement("p"); reference.className = "preview-source"; + reference.textContent = referenceText(item); content.append(reference); + } + } + $("export-preview").showModal(); }); +$("close-preview-btn").addEventListener("click", () => $("export-preview").close()); +restoreSavedProject(); updateModeText(); updateCount(); render(); diff --git a/src/main/resources/static/background.js b/src/main/resources/static/background.js new file mode 100644 index 0000000..80fb06c --- /dev/null +++ b/src/main/resources/static/background.js @@ -0,0 +1,74 @@ +/* Decorative document particles. No document content is read by this effect. */ +(() => { + "use strict"; + const canvas = document.getElementById("ambient-background"); + const context = canvas?.getContext("2d"); + if (!context) return; + const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); + let width = 0, height = 0, frame = 0, previous = 0; + let points = []; + const pointer = {x: -1000, y: -1000}; + + function resize() { + width = innerWidth; height = innerHeight; + const scale = Math.min(devicePixelRatio || 1, 1.5); + canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); + context.setTransform(scale, 0, 0, scale, 0, 0); + points = Array.from({length: width < 700 ? 20 : 42}, () => ({ + x: Math.random() * width, y: Math.random() * height, + vx: (Math.random() - 0.5) * 0.35, vy: (Math.random() - 0.5) * 0.35, + radius: 1 + Math.random() * 1.2 + })); + } + + function paint(now) { + if (now && now - previous < 33) { frame = requestAnimationFrame(paint); return; } + const delta = previous ? Math.min((now - previous) / 33, 2) : 1; + previous = now; + context.clearRect(0, 0, width, height); + const visible = points.map((point) => { + if (!reducedMotion.matches) { + point.x = (point.x + point.vx * delta + width) % width; + point.y = (point.y + point.vy * delta + height) % height; + } + const dx = point.x - pointer.x, dy = point.y - pointer.y; + const distance = Math.hypot(dx, dy); + const push = !reducedMotion.matches && distance < 180 ? (180 - distance) * 0.12 : 0; + return {x: point.x + dx / (distance || 1) * push, y: point.y + dy / (distance || 1) * push, radius: point.radius}; + }); + for (let i = 0; i < visible.length; i++) { + const point = visible[i]; + context.fillStyle = i % 5 ? "rgba(131,239,192,0.32)" : "rgba(234,213,165,0.4)"; + context.beginPath(); context.arc(point.x, point.y, point.radius, 0, Math.PI * 2); context.fill(); + for (let j = i + 1; j < visible.length; j++) { + const other = visible[j]; + const distance = Math.hypot(point.x - other.x, point.y - other.y); + if (distance > 150) continue; + context.strokeStyle = "rgba(131,239,192," + (0.1 * (1 - distance / 150)) + ")"; + context.lineWidth = 0.7; context.beginPath(); context.moveTo(point.x, point.y); + context.lineTo(other.x, other.y); context.stroke(); + } + } + if (!reducedMotion.matches && !document.hidden) frame = requestAnimationFrame(paint); + } + + function restart() { + cancelAnimationFrame(frame); previous = 0; + if (!document.hidden) paint(0); + } + addEventListener("resize", () => { resize(); restart(); }, {passive: true}); + addEventListener("pointermove", (event) => { + if (reducedMotion.matches) return; + pointer.x = event.clientX; pointer.y = event.clientY; + document.body.style.setProperty("--pointer-x", (pointer.x / width * 100) + "%"); + document.body.style.setProperty("--pointer-y", (pointer.y / height * 100) + "%"); + }, {passive: true}); + document.documentElement.addEventListener("pointerleave", () => { pointer.x = pointer.y = -1000; }); + document.addEventListener("visibilitychange", restart); + reducedMotion.addEventListener("change", () => { + pointer.x = pointer.y = -1000; + document.body.style.removeProperty("--pointer-x"); document.body.style.removeProperty("--pointer-y"); + restart(); + }); + resize(); restart(); +})(); diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 264e141..d9e3466 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -7,9 +7,12 @@ Doc2FAQ — Clear answers start here + + + +
+
+ + +

The heading printed at the top of your exported FAQ.

+
+ + + +

Save work keeps your source and edits for later. Use Download for the finished FAQ.

+
+ + + Autosave off +
+
STEP 01

Add your document

@@ -53,12 +72,16 @@

Turn long documents into clear answers.

Extract questions already written in the document.

- +
+ + +
+

No document loaded

0 / 50,000 charactersUTF-8 text
- +
@@ -68,6 +91,12 @@

Turn long documents into clear answers.

STEP 02

Review your FAQ

0 ITEMS

Explicit questions and their written answers are extracted. Edit and verify before sharing.

+ +
+ + +
+

Edit this name before downloading. The extension follows your chosen format.

@@ -76,14 +105,20 @@

Turn long documents into clear answers.

+ +
- Edit answers and reorder items before sharing +
+
+ + +
@@ -97,10 +132,15 @@

Your answers will appear here.

© 2026 Okan TiktasDoc2FAQ · v@project.version@ · Built for clarity
+ +

Export preview

+

Content preview. PDF and Word downloads apply their own page layout.

+
+
diff --git a/src/main/resources/static/project.js b/src/main/resources/static/project.js new file mode 100644 index 0000000..d43ba87 --- /dev/null +++ b/src/main/resources/static/project.js @@ -0,0 +1,128 @@ +/* Shared project validation and review logic, with no DOM or dependencies. */ +(function (root) { + "use strict"; + const MAX_PROJECT_BYTES = 4 * 1024 * 1024; + let sequence = 0; + const id = () => "faq-" + Date.now().toString(36) + "-" + (++sequence).toString(36); + const lines = (text) => text.split(/\r\n|[\n\r\v\f\u0085\u2028\u2029]/); + const fail = (message) => { throw new Error(message); }; + function string(value, max, label) { + if (typeof value !== "string" || value.length > max) fail("Invalid " + label + "."); + return value; + } + function normalizeItem(item) { + return { + id: item.id || id(), question: item.question, answer: item.answer, + originalQuestion: item.originalQuestion ?? item.question, + originalAnswer: item.originalAnswer ?? item.answer, + sources: item.sources || (item.source ? [item.source] : []), + keptWith: item.keptWith || [], edited: item.edited === true + }; + } + function edited(item) { + return item.edited === true || item.question !== item.originalQuestion || item.answer !== item.originalAnswer; + } + function sourceTitle(name) { + return name.split(/[\\/]/).pop().replace(/\.[^.]+$/, "").trim().slice(0, 200) || "FAQ draft"; + } + function defaultDownloadName(name) { + const stem = sourceTitle(name).normalize("NFKC").toLowerCase() + .replace(/-doc2faq$/i, "").replace(/[^\p{L}\p{N}._-]/gu, "").replace(/^\.+|\.+$/g, "").slice(0, 160); + return (stem || "document") + "-Doc2FAQ"; + } + function downloadFilename(name, format) { + const ext = {markdown: "md", json: "json", html: "html", pdf: "pdf", docx: "docx"}[format] || "md"; + let stem = (name || "").trim().replace(/\.(pdf|docx|md|json|html)$/i, "") + .replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/g, "-").replace(/^\.+|[. ]+$/g, "").slice(0, 180); + if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(stem)) stem = "document-" + stem; + return (stem || "document-Doc2FAQ") + "." + ext; + } + function validateProject(project) { + if (!project || project.type !== "doc2faq-project" || project.schemaVersion !== 1) { + fail("Choose a supported Doc2FAQ project file (schema version 1)."); + } + const title = string(project.title, 200, "project title"); + if (!["extract", "generate"].includes(project.mode)) fail("Invalid project mode."); + const text = string(project.source?.text, 50000, "source text"); + const name = string(project.source?.name, 255, "source filename"); + const downloadName = string(project.downloadName ?? defaultDownloadName(name), 180, "download filename"); + if (!Array.isArray(project.items) || project.items.length > 30) fail("A project can contain at most 30 FAQs."); + const sourceLines = lines(text); + const ids = new Set(); + let answerCharacters = 0; + const items = project.items.map((raw) => { + if (!raw || typeof raw !== "object") fail("Invalid FAQ entry."); + const itemId = string(raw.id, 100, "FAQ identifier"); + if (!itemId || ids.has(itemId)) fail("FAQ identifiers must be unique."); + ids.add(itemId); + const question = string(raw.question, 500, "FAQ question"); + const answer = string(raw.answer, 50000, "FAQ answer"); + answerCharacters += question.length + answer.length; + const originalQuestion = string(raw.originalQuestion, 50000, "original question"); + const originalAnswer = string(raw.originalAnswer, 50000, "original answer"); + if (!Array.isArray(raw.sources) || raw.sources.length > 30) fail("Invalid source references."); + const sources = raw.sources.map((reference) => { + if (!reference || typeof reference !== "object") fail("Invalid source reference."); + const section = string(reference.section, 50000, "source section"); + const excerpt = string(reference.excerpt, 50000, "source excerpt"); + const {startLine, endLine} = reference; + if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1 || + endLine < startLine || endLine > sourceLines.length || !excerpt || + sourceLines.slice(startLine - 1, endLine).join("\n") !== excerpt) { + fail("A source reference does not match the project's document text."); + } + return {section, startLine, endLine, excerpt}; + }); + if (!Array.isArray(raw.keptWith) || raw.keptWith.length > 30 || + raw.keptWith.some((value) => typeof value !== "string" || value.length > 100)) fail("Invalid review choices."); + return {id: itemId, question, answer, originalQuestion, originalAnswer, sources, + keptWith: [...raw.keptWith], edited: raw.edited === true}; + }); + if (answerCharacters > 300000) fail("FAQ text must not exceed 300,000 characters."); + for (const item of items) item.keptWith = item.keptWith.filter((key) => ids.has(key) && key !== item.id); + return {type: "doc2faq-project", schemaVersion: 1, title, downloadName, mode: project.mode, source: {text, name}, items}; + } + function serialize(project) { + const text = JSON.stringify(validateProject(project), null, 2) + "\n"; + if (new TextEncoder().encode(text).length > MAX_PROJECT_BYTES) fail("Project files must not exceed 4 MB."); + return text; + } + function parse(text) { + if (new TextEncoder().encode(text).length > MAX_PROJECT_BYTES) fail("Project files must not exceed 4 MB."); + let data; + try { data = JSON.parse(text); } catch { fail("This file is not valid JSON."); } + return validateProject(data); + } + function words(question) { + return new Set(question.normalize("NFKC").toLowerCase().match(/[\p{L}\p{N}]+/gu) || []); + } + function similar(a, b) { + const first = words(a), second = words(b); + if (!first.size || !second.size) return false; + for (const party of ["client", "provider"]) { + if (first.has(party) !== second.has(party)) return false; + } + const shared = [...first].filter((word) => second.has(word)).length; + return shared / (first.size + second.size - shared) >= 0.7; + } + function related(items, item) { + return items.filter((other) => other !== item && !item.keptWith.includes(other.id) && + !other.keptWith.includes(item.id) && similar(item.question, other.question)); + } + function merge(first, second) { + const answer = first.answer.trim() === second.answer.trim() ? first.answer : + [first.answer.trim(), second.answer.trim()].filter(Boolean).join("\n\n"); + if (answer.length > 50000) fail("The merged answer would exceed 50,000 characters. Shorten it first."); + const sources = [...first.sources]; + for (const source of second.sources) { + if (!sources.some((saved) => saved.startLine === source.startLine && saved.endLine === source.endLine && + saved.excerpt === source.excerpt)) sources.push({...source}); + } + if (sources.length > 30) fail("The merged FAQ would have too many source references."); + return {...first, answer, sources, keptWith: [], edited: true}; + } + const api = {MAX_PROJECT_BYTES, normalizeItem, edited, sourceTitle, defaultDownloadName, downloadFilename, + validateProject, serialize, parse, similar, related, merge}; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.Doc2FaqProject = api; +})(globalThis); diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css index 9e06d29..878a684 100644 --- a/src/main/resources/static/styles.css +++ b/src/main/resources/static/styles.css @@ -56,3 +56,32 @@ select:focus-visible{outline:3px solid var(--mint);outline-offset:3px} .export-select{min-width:0} .review-toolbar{flex-wrap:wrap} } + +/* Saved projects, source review, and export preview */ +.project-toolbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:20px;margin-bottom:18px;background:var(--surface);border:1px solid var(--line);border-radius:12px} +.project-name{flex:1;min-width:180px}.project-name .field-label{margin-bottom:6px} +.project-name input{width:100%;padding:10px 12px;background:#0c1829;color:var(--text);border:1px solid #385066;border-radius:7px} +.autosave-controls{flex-basis:100%;display:flex;align-items:center;gap:14px;flex-wrap:wrap;color:var(--muted);font-size:12px} +.autosave-controls label,.export-options label{display:flex;align-items:center;gap:8px;line-height:1.5}.autosave-controls input,.export-options input{accent-color:var(--mint);width:16px;height:16px;flex-shrink:0} +#save-status{font-size:11px}.source-badge{font-size:11px;line-height:1.5;color:var(--gold);margin:10px 0} +.source-details,.similar-review{font-size:12px;line-height:1.6;border-top:1px solid var(--line);padding:10px 0;overflow-wrap:anywhere} +.source-details summary,.similar-review summary{cursor:pointer;color:var(--mint)}.source-details pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.6 ui-monospace,monospace;color:#ceddea;max-height:220px;overflow:auto;padding:12px;background:#0c1829;border-radius:6px} +.similar-review summary{color:var(--gold)}.similar-review .editor-action{margin-right:7px;margin-bottom:7px}.similar-review p{margin:10px 0} +.export-options{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;font-size:11px;color:var(--muted);margin:0 0 14px} +.result-actions{grid-template-columns:minmax(0,1fr) auto auto}.result-actions input{grid-column:1/-1} +.results-list{max-height:680px}.faq-editor-actions{flex-wrap:wrap} +#export-preview{max-width:860px;width:calc(100% - 32px);max-height:90vh;padding:28px;border:1px solid #385066;border-radius:14px;background:var(--surface);color:var(--text)} +#export-preview::backdrop{background:#000a}.preview-header{display:flex;justify-content:space-between;align-items:center;gap:12px}.preview-header h2{font-size:20px}.preview-note{color:var(--muted);font-size:12px;line-height:1.5} +#preview-content{background:#fff;color:#172233;border-radius:6px;padding:32px;margin-top:18px;overflow-wrap:anywhere}#preview-content h1{font:700 28px/1.25 system-ui,sans-serif;letter-spacing:normal}#preview-content h2{font:700 19px/1.4 system-ui,sans-serif;margin:28px 0 10px}#preview-content p{white-space:pre-wrap;font:14px/1.7 system-ui,sans-serif}#preview-content .preview-source{font-size:11px;color:#475569} +@media(max-width:620px){.project-toolbar{padding:16px}.project-name{flex-basis:100%}.autosave-controls{gap:8px}.preview-header{flex-wrap:wrap}#preview-content{padding:20px}.result-actions{grid-template-columns:1fr 1fr}.result-actions .export-select{grid-column:1/-1}.faq-editor-heading{flex-wrap:wrap}} + +/* Ambient canvas and document download controls */ +body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;background:radial-gradient(ellipse 650px 500px at var(--pointer-x,65%) var(--pointer-y,15%),#4a9e9320,transparent 75%),radial-gradient(ellipse at 12% 75%,#c8a76b0c,transparent 60%)} +#ambient-background{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:0} +.app-shell{position:relative;z-index:1}.field-help{font-size:12px;line-height:1.55;color:var(--muted);margin:7px 0 0} +.save-work-help{flex-basis:100%;font-size:12px;color:var(--muted);line-height:1.5;margin:0} +.document-content-heading{display:flex;align-items:center;justify-content:space-between;gap:10px}.document-content-heading .field-label{margin:0}.document-content-heading .button{padding:6px 9px;min-height:32px;font-size:11px;color:var(--mint)} +.source-filename{color:var(--muted);font-size:11px;line-height:1.5;margin:4px 0 10px;overflow-wrap:anywhere} +.download-name-field{display:flex;align-items:center;border:1px solid #385066;background:#0c1829;border-radius:8px;overflow:hidden}.download-name-field input{min-width:0;flex:1;width:100%;border:0;padding:11px 12px;background:transparent;color:var(--text);font-size:13px}.download-name-field span{padding:10px 12px;color:var(--mint);font:12px ui-monospace,monospace;border-left:1px solid var(--line)} +#download-name-help{margin-bottom:16px}#preview-content h1{text-align:center} +@media(prefers-reduced-motion:reduce){body::before{background:radial-gradient(ellipse at 65% 15%,#4a9e9315,transparent 70%)}#ambient-background{opacity:.4}} diff --git a/src/test/java/com/okansoftware/doc2faq/api/ExportIntegrationTest.java b/src/test/java/com/okansoftware/doc2faq/api/ExportIntegrationTest.java new file mode 100644 index 0000000..bfbdc2f --- /dev/null +++ b/src/test/java/com/okansoftware/doc2faq/api/ExportIntegrationTest.java @@ -0,0 +1,152 @@ +package com.okansoftware.doc2faq.api; + +import com.okansoftware.doc2faq.Doc2FaqApplication; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.text.TextPosition; +import org.apache.poi.xwpf.usermodel.ParagraphAlignment; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.extractor.XWPFWordExtractor; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.http.ContentDisposition; +import org.springframework.test.web.servlet.MockMvc; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(classes = Doc2FaqApplication.class) +@AutoConfigureMockMvc +class ExportIntegrationTest { + @Autowired private MockMvc mvc; + private static final String BODY = """ + {"title":"Team FAQ İstanbul","includeSources":true,"items":[ + {"question":"How do we share notes?","answer":"Use the café guide.\\nKeep