Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

All notable Doc2FAQ changes are recorded here. Version numbers follow semantic versioning; entries describe shipped source functionality and do not imply an external AI service.

## [0.5.0] — Unreleased

### Added
- Read-only source viewer highlighting the selected FAQ's exact original passages, with a stacked layout on narrow screens.
- Per-FAQ Needs review / Approved status, persisted in saved projects and optional browser autosave.
- Combined question/answer search and review-status filters, visible result counts, and Clear filters.

### Changed
- Editing questions or answers and merging FAQs reset approval; incomplete FAQs cannot be approved.
- Project schema 2 stores review status and upgrades schema-1 projects with Needs review defaults. Older application versions reject schema-2 files instead of silently discarding approval state.
- Filters affect only the review list; saving, export preview, and all export formats retain every FAQ in draft order.
- Development version is 0.5.0-SNAPSHOT; v0.4.0 remains the published release.

## 0.4.0 — 2026-10-29

### Added
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ Created by **Okan Tiktas** · [OKAN-SOFTWARE](https://github.com/OKAN-SOFTWARE).

> **Current version: v0.4.0 — Review, Save & Export.** Download the ready-to-run JAR or Windows ZIP from [GitHub Releases](https://github.com/OKAN-SOFTWARE/Doc2FAQ/releases/tag/v0.4.0).

## In development: v0.5.0 — Source & Review

- **Source beside answer:** select **View source beside answer** on an FAQ to highlight all of its original passages in a read-only document viewer beside the review editor on desktop (stacked on smaller screens). References still use extracted-text lines, not PDF page numbers. Manual FAQs have no automatic reference.
- **Review status:** FAQs start as **Needs review**. Mark a complete answer **Approved** after checking it. Editing its question or answer, or merging FAQs, returns it to **Needs review**. Moving an item preserves approval; Undo restores the status from before a removal or merge.
- **Search and review filters:** combine a case-insensitive question/answer search with **All statuses**, **Needs review**, or **Approved**. The summary shows visible, approved, and pending counts. **Clear filters** restores the full list. Filters never change what is saved, previewed, or exported: all FAQs remain included, and incomplete entries still block export.
- **Saved work compatibility:** approval status is preserved in project downloads and optional autosave. New saves use project schema 2; v0.4 schema-1 files still open with FAQs marked Needs review. Schema-2 files require v0.5 or later to reopen.

Development builds use `0.5.0-SNAPSHOT` in `pom.xml` (`target/doc2faq-0.5.0-SNAPSHOT.jar`). The latest published download remains v0.4.0 until a new release is validated and published.

## New in v0.4.0

- **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.
Expand Down Expand Up @@ -167,15 +176,15 @@ 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.4.0.jar
java -jar target/doc2faq-0.5.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.

If port 8080 is occupied, stop the previous Doc2FAQ process or use an alternate port:

```cmd
java -jar target/doc2faq-0.4.0.jar --server.port=8081
java -jar target/doc2faq-0.5.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.
Expand Down
13 changes: 13 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

The release version is **v0.4.0**. The release commit uses `0.4.0` in `pom.xml`; earlier development builds used `0.4.0-SNAPSHOT`. Merging source changes alone does not publish a GitHub Release or update an already downloaded JAR.

## v0.5.0 development checks

The next development version is `0.5.0-SNAPSHOT`; use `target/doc2faq-0.5.0-SNAPSHOT.jar` when testing a source build. The v0.4.0 publishing instructions below describe the existing release; update the release version and notes before publishing v0.5.0.

Before preparing v0.5.0:

1. Select an FAQ's source viewer and check every highlighted passage, including multiple references after merge. Try a manual FAQ and a document containing HTML-like text. Check desktop side-by-side and mobile stacked layouts.
2. Approve a complete FAQ; edit its question or answer and confirm it needs review again. Confirm blank FAQs cannot be approved. Remove/Undo and reorder approved FAQs.
3. Combine search and status filters, edit a matching FAQ, check counts, and clear filters. Add an FAQ while filtering and confirm it is visible.
4. Save/reopen and autosave/reload approvals; import a schema-1 project and confirm Needs review defaults. Reject malformed review states without replacing the workspace.
5. With filters active, preview and export all five formats. Confirm all FAQs remain included in draft order; a hidden blank entry must still prevent export.
6. Run the Java and JavaScript checks below and smoke-test the packaged JAR.

## Validate the release build

Use JDK 21, Maven 3.6.3+, and Node.js:
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</parent>
<groupId>com.okansoftware</groupId>
<artifactId>doc2faq</artifactId>
<version>0.4.0</version>
<version>0.5.0-SNAPSHOT</version>
<name>Doc2FAQ</name>
<description>Privacy-conscious document-to-FAQ preview workbench</description>
<properties>
Expand Down
154 changes: 128 additions & 26 deletions src/main/resources/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,17 @@ let undoHistory = [];
let saveTimer;
let importRevision = 0;
let reviewUpdaters = [];
let filterUpdaters = [];
let activeSourceId = null;
let sourceViewKey = null;
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(),
return {type: "doc2faq-project", schemaVersion: 2, title: projectTitle(), mode: selectedMode(),
downloadName: $("download-name").value, source: {text: source.value, name: sourceName}, items};
}

Expand Down Expand Up @@ -98,7 +101,7 @@ function applyProject(project) {
modeSelect.value = project.mode;
items = project.items;
undoHistory = [];
search.value = "";
resetReviewView();
updateModeText(); updateCount(); render(); autosave();
}

Expand Down Expand Up @@ -150,6 +153,8 @@ function updateControls() {
const hasItems = items.length > 0;
const ready = validDraft();
search.disabled = !hasItems;
$("review-filter").disabled = !hasItems;
$("clear-filters-btn").disabled = !search.value && !["needs-review", "approved"].includes($("review-filter").value);
copyButton.disabled = !ready || ["pdf", "docx"].includes(formatSelect.value);
downloadButton.disabled = !ready || exporting;
formatSelect.disabled = !hasItems;
Expand Down Expand Up @@ -184,35 +189,95 @@ function move(item, offset) {
render(); autosave();
}

function render() {
reviewUpdaters = [];
function resetReviewView() {
search.value = "";
$("review-filter").value = "all";
activeSourceId = null;
}

function matchesReview(item) {
const term = search.value.trim().toLocaleLowerCase();
const shown = items.filter((item) =>
(item.question + " " + item.answer).toLocaleLowerCase().includes(term));
results.replaceChildren();
const filter = $("review-filter").value;
return (item.question + " " + item.answer).toLocaleLowerCase().includes(term) &&
(!["approved", "needs-review"].includes(filter) || item.reviewStatus === filter);
}

function updateReviewSummary() {
const approved = items.filter((item) => item.reviewStatus === "approved").length;
const shown = items.filter(matchesReview).length;
$("review-summary").textContent = shown + " of " + items.length + " FAQs shown · " +
approved + " approved · " + (items.length - approved) + " need review";
$("no-matches").hidden = shown > 0;
for (const update of filterUpdaters) update();
updateControls();
renderSourceViewer();
}

if (!shown.length) {
const empty = document.createElement("div");
empty.className = "empty-state";
const icon = document.createElement("div");
icon.className = "empty-icon";
icon.textContent = "?";
const title = document.createElement("h4");
title.textContent = items.length ? "No matching answers." : "Your answers will appear here.";
const hint = document.createElement("p");
hint.textContent = items.length
? "Try a different search term."
: "Load the example or add text on the left, then create your first draft.";
empty.append(icon, title, hint);
results.append(empty);
function renderSourceViewer(revealSelection = false) {
const item = items.find((entry) => entry.id === activeSourceId && matchesReview(entry));
const viewer = $("source-viewer");
const content = $("source-viewer-content");
viewer.hidden = !item;
if (!item) {
content.replaceChildren();
sourceViewKey = null;
return;
}
$("source-viewer-title").textContent = "Source for FAQ " + (items.indexOf(item) + 1);
const key = JSON.stringify([item.id, source.value, item.sources]);
if (key === sourceViewKey) {
if (revealSelection) content.querySelector(".source-line-selected")?.scrollIntoView?.({block: "nearest"});
return;
}
sourceViewKey = key;
content.replaceChildren();
$("source-viewer-note").textContent = item.sources.length
? "Highlighted passages are the original source. Compare them with your edited answer. References use extracted-text lines, not PDF pages."
: "This FAQ was added manually and has no automatic source reference.";
const sourceLines = source.value.split(/\r\n|[\n\r\v\f\u0085\u2028\u2029]/);
const selected = new Set();
for (const ref of item.sources) {
if (Number.isInteger(ref.startLine) && Number.isInteger(ref.endLine) && ref.startLine >= 1 &&
ref.endLine >= ref.startLine && ref.endLine <= sourceLines.length &&
sourceLines.slice(ref.startLine - 1, ref.endLine).join("\n") === ref.excerpt) {
for (let line = ref.startLine; line <= ref.endLine; line++) selected.add(line);
}
}
if (item.sources.length && !selected.size) {
$("source-viewer-note").textContent = "The saved references do not match this document. Check the source manually.";
}
for (const [index, text] of sourceLines.entries()) {
const row = document.createElement("div");
row.className = selected.has(index + 1) ? "source-line source-line-selected" : "source-line";
const label = document.createElement("span");
label.className = "source-line-number";
label.textContent = String(index + 1);
const passage = document.createElement(selected.has(index + 1) ? "mark" : "span");
passage.textContent = text || " ";
row.append(label, passage); content.append(row);
}
if (revealSelection) content.querySelector(".source-line-selected")?.scrollIntoView?.({block: "nearest"});
}

function render() {
reviewUpdaters = [];
filterUpdaters = [];
results.replaceChildren();
const empty = document.createElement("div");
empty.id = "no-matches";
empty.className = "empty-state";
const title = document.createElement("h4");
title.textContent = items.length ? "No matching answers." : "Your answers will appear here.";
const hint = document.createElement("p");
hint.textContent = items.length ? "Try another search or clear the review filters." :
"Load the example or add text on the left, then create your first draft.";
empty.append(title, hint); results.append(empty);

for (const item of shown) {
for (const item of items) {
const index = items.indexOf(item);
const card = document.createElement("section");
card.className = "faq-editor";
filterUpdaters.push(() => { card.hidden = !matchesReview(item); });

const heading = document.createElement("div");
heading.className = "faq-editor-heading";
Expand Down Expand Up @@ -248,6 +313,8 @@ function render() {
draftRevision++;
undoHistory = [];
item.question = question.value;
item.reviewStatus = "needs-review";
updateReviewStatus(); updateReviewSummary();
updateControls(); updateBadge();
for (const update of reviewUpdaters) update();
autosave();
Expand All @@ -267,10 +334,39 @@ function render() {
draftRevision++;
undoHistory = [];
item.answer = answer.value;
item.reviewStatus = "needs-review";
updateReviewStatus(); updateReviewSummary();
updateControls(); updateBadge(); autosave();
});

const reviewDetails = document.createElement("div");
const reviewLabel = document.createElement("label");
reviewLabel.className = "field-label";
reviewLabel.htmlFor = "faq-review-" + index;
reviewLabel.textContent = "REVIEW STATUS";
const reviewSelect = document.createElement("select");
reviewSelect.id = reviewLabel.htmlFor;
reviewSelect.className = "review-status-select";
for (const [value, text] of [["needs-review", "Needs review"], ["approved", "Approved"]]) {
const option = document.createElement("option");
option.value = value; option.textContent = text; reviewSelect.append(option);
}
function updateReviewStatus() { reviewSelect.value = item.reviewStatus; }
updateReviewStatus();
reviewSelect.addEventListener("change", () => {
if (reviewSelect.value === "approved" && (!item.question.trim() || !item.answer.trim())) {
updateReviewStatus();
setStatus("Complete the question and answer before approving this FAQ.", "error"); return;
}
draftRevision++; undoHistory = [];
item.reviewStatus = reviewSelect.value === "approved" ? "approved" : "needs-review";
updateReviewSummary(); autosave();
});
const viewButton = makeButton("View source beside answer", "editor-action", () => {
activeSourceId = item.id; renderSourceViewer(true);
$("source-viewer").scrollIntoView?.({block: "nearest"});
});
reviewDetails.append(reviewLabel, reviewSelect, viewButton);
const badge = document.createElement("p");
badge.className = "source-badge";
function updateBadge() {
Expand Down Expand Up @@ -332,6 +428,7 @@ function render() {
card.append(heading, questionLabel, question, answerLabel, answer, reviewDetails);
results.append(card);
}
updateReviewSummary();
}

function markdown() {
Expand Down Expand Up @@ -394,7 +491,7 @@ function clearDraft() {
updateModeText();
items = [];
undoHistory = [];
search.value = "";
resetReviewView();
render();
}

Expand All @@ -405,7 +502,12 @@ source.addEventListener("input", () => {
clearDraft(); autosave();
setStatus("Source updated. Create a new FAQ draft.");
});
search.addEventListener("input", render);
search.addEventListener("input", updateReviewSummary);
$("review-filter").addEventListener("change", updateReviewSummary);
$("clear-filters-btn").addEventListener("click", () => {
search.value = ""; $("review-filter").value = "all"; updateReviewSummary();
});
$("close-source-btn").addEventListener("click", () => { activeSourceId = null; renderSourceViewer(); });
modeSelect.addEventListener("change", () => {
updateModeText();
clearDraft(); autosave();
Expand All @@ -428,7 +530,7 @@ $("sample-btn").addEventListener("click", () => {

addButton.addEventListener("click", () => {
if (items.length >= MAX_ITEMS) return;
search.value = "";
resetReviewView();
draftRevision++;
undoHistory = [];
items.push(projectTools.normalizeItem({question: "", answer: ""}));
Expand Down Expand Up @@ -540,7 +642,7 @@ draftButton.addEventListener("click", async () => {
.slice(0, MAX_ITEMS).map(projectTools.normalizeItem)
: [];
undoHistory = [];
search.value = "";
resetReviewView();
render(); autosave();
setStatus(items.length
? "Created " + items.length + " FAQ item(s). Edit and verify before exporting."
Expand Down
16 changes: 14 additions & 2 deletions src/main/resources/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ <h1 id="hero-title">Turn long documents into <em>clear answers.</em></h1>
</div>
<p id="source-filename" class="source-filename">No document loaded</p>
<textarea id="source-text" maxlength="50000" spellcheck="false" placeholder="Q: How can I organize my notes?&#10;A: Use clear headings and group related topics.&#10;&#10;Q: What makes a helpful answer?&#10;A: Keep it clear, concise, and based on the source."></textarea>
<section id="source-viewer" class="source-viewer" aria-labelledby="source-viewer-title" hidden>
<div class="source-viewer-heading"><h4 id="source-viewer-title">Original source</h4><button type="button" id="close-source-btn" class="button button-quiet">Close source view</button></div>
<p id="source-viewer-note" class="field-help"></p>
<div id="source-viewer-content" class="source-viewer-content" tabindex="0" aria-label="Original source with highlighted FAQ passages"></div>
</section>
<div class="input-meta"><span id="char-count">0 / 50,000 characters</span><span>UTF-8 text</span></div>
<div class="actions">
<button type="button" id="generate-btn" class="button button-primary">Extract FAQ draft <span aria-hidden="true">↗</span></button>
Expand All @@ -98,8 +103,8 @@ <h1 id="hero-title">Turn long documents into <em>clear answers.</em></h1>
</div>
<p id="download-name-help" class="field-help">Edit this name before downloading. The extension follows your chosen format.</p>
<div class="result-actions">
<label class="visually-hidden" for="search-input">Search draft questions</label>
<input id="search-input" type="search" placeholder="Search draft answers..." disabled>
<label class="visually-hidden" for="search-input">Search questions and answers</label>
<input id="search-input" type="search" placeholder="Search questions and answers..." disabled>
<label class="visually-hidden" for="export-format">Export format</label>
<select id="export-format" class="export-select" disabled>
<option value="markdown">Markdown</option>
Expand All @@ -111,6 +116,13 @@ <h1 id="hero-title">Turn long documents into <em>clear answers.</em></h1>
<button type="button" id="copy-btn" class="button button-secondary" disabled>Copy</button>
<button type="button" id="download-btn" class="button button-secondary" disabled>Download .md</button>
</div>
<div class="review-filters">
<label for="review-filter" class="field-label">SHOW FAQs</label>
<select id="review-filter" disabled><option value="all">All statuses</option><option value="needs-review">Needs review</option><option value="approved">Approved</option></select>
<button type="button" id="clear-filters-btn" class="button button-quiet" disabled>Clear filters</button>
</div>
<p id="review-summary" class="field-help" role="status" aria-live="polite"></p>
<p class="field-help">Search and filters change this view. Exports always include all FAQs.</p>
<div class="review-toolbar">
<button type="button" id="undo-btn" class="button button-quiet" disabled>Undo removal / merge</button>
<button type="button" id="add-item-btn" class="button button-quiet">+ Add FAQ</button>
Expand Down
Loading
Loading