init
This commit is contained in:
commit
515596ba2b
12 changed files with 427 additions and 0 deletions
1
assets/.gitignore
vendored
Normal file
1
assets/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/grammalecte
|
19
assets/update_grammalecte
Executable file
19
assets/update_grammalecte
Executable file
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/bash
|
||||
set -e
|
||||
cd "$(dirname "$(realpath "$0")")"
|
||||
|
||||
|
||||
dl_ref="$(wget -qO- https://grammalecte.net/ | grep ' <a class="button" href="zip/Grammalecte-fr-' | head -n 1 | cut -d '"' -f 4)"
|
||||
if [ ".$dl_ref" = "." ]
|
||||
then echo "[update_grammalecte] Failed to get latest grammalecte." && exit 1
|
||||
fi
|
||||
|
||||
|
||||
rm -fr grammalecte
|
||||
mkdir -p grammalecte
|
||||
wget -O ./grammalecte/grammalecte.zip "https://grammalecte.net/$dl_ref"
|
||||
(
|
||||
cd grammalecte
|
||||
unzip grammalecte.zip
|
||||
|
||||
)
|
7
deno.json
Normal file
7
deno.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"fmt": {
|
||||
"useTabs": true,
|
||||
"lineWidth": 120,
|
||||
"semiColons": false
|
||||
}
|
||||
}
|
44
deno.lock
generated
Normal file
44
deno.lock
generated
Normal file
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"version": "4",
|
||||
"specifiers": {
|
||||
"jsr:@hono/hono@4.8.4": "4.8.4",
|
||||
"jsr:@std/assert@1.0.13": "1.0.13",
|
||||
"jsr:@std/fs@1.0.19": "1.0.19",
|
||||
"jsr:@std/internal@^1.0.6": "1.0.9",
|
||||
"jsr:@std/internal@^1.0.9": "1.0.9",
|
||||
"jsr:@std/path@^1.1.1": "1.1.1",
|
||||
"npm:zod@4.0.2": "4.0.2"
|
||||
},
|
||||
"jsr": {
|
||||
"@hono/hono@4.8.4": {
|
||||
"integrity": "dabc0ea1185040fc1e89abfe29906d9e3dd425a4c40f903f9ead78c48917da06"
|
||||
},
|
||||
"@std/assert@1.0.13": {
|
||||
"integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal@^1.0.6"
|
||||
]
|
||||
},
|
||||
"@std/fs@1.0.19": {
|
||||
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal@^1.0.9",
|
||||
"jsr:@std/path"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.9": {
|
||||
"integrity": "bdfb97f83e4db7a13e8faab26fb1958d1b80cc64366501af78a0aee151696eb8"
|
||||
},
|
||||
"@std/path@1.1.1": {
|
||||
"integrity": "fe00026bd3a7e6a27f73709b83c607798be40e20c81dde655ce34052fd82ec76",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal@^1.0.9"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"zod@4.0.2": {
|
||||
"integrity": "sha512-X2niJNY54MGam4L6Kj0AxeedeDIi/E5QFW0On2faSX5J4/pfLk1tW+cRMIMoojnCavn/u5W/kX17e1CSGnKMxA=="
|
||||
}
|
||||
}
|
||||
}
|
50
src/common.ts
Normal file
50
src/common.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { z } from "npm:zod@4.0.2"
|
||||
|
||||
export type Request = z.infer<ReturnType<typeof request_schema>>
|
||||
export function request_schema() {
|
||||
return z.object({
|
||||
text: z.string(),
|
||||
})
|
||||
}
|
||||
|
||||
export function item_schema() {
|
||||
return z.object({
|
||||
"iParagraph": z.number(),
|
||||
"lGrammarErrors": z.array(grammar_error_schema()),
|
||||
"lSpellingErrors": z.array(spelling_error_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
export function grammar_error_schema() {
|
||||
return z.object({
|
||||
"nStart": z.number(),
|
||||
"nEnd": z.number(),
|
||||
"sLineId": z.string(),
|
||||
"sRuleId": z.string(),
|
||||
"sType": z.string(),
|
||||
"aColor": z.tuple([z.number(), z.number(), z.number()]),
|
||||
"sMessage": z.string(),
|
||||
"aSuggestions": z.array(z.string()),
|
||||
"URL": z.string(),
|
||||
})
|
||||
}
|
||||
|
||||
export function spelling_error_schema() {
|
||||
return z.object({
|
||||
"i": z.number(),
|
||||
"sType": z.literal("WORD"),
|
||||
"sValue": z.string(),
|
||||
"nStart": z.number(),
|
||||
"nEnd": z.number(),
|
||||
"aSuggestions": z.array(z.string()),
|
||||
})
|
||||
}
|
||||
|
||||
export type Output = z.infer<ReturnType<typeof output_schema>>
|
||||
export function output_schema() {
|
||||
return z.object({
|
||||
"grammalecte": z.string(),
|
||||
"lang": z.literal("fr"),
|
||||
"data": z.array(item_schema()),
|
||||
})
|
||||
}
|
62
src/lib/grammalecte.ts
Normal file
62
src/lib/grammalecte.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { exists } from "jsr:@std/fs@1.0.19"
|
||||
import { assertEquals } from "jsr:@std/assert@1.0.13"
|
||||
import { Output, output_schema } from "../common.ts"
|
||||
|
||||
async function run(...args_: string[]) {
|
||||
const script_url = new URL("../../assets/grammalecte/grammalecte-cli.py", import.meta.url)
|
||||
const update_url = new URL("../../assets/update_grammalecte", import.meta.url)
|
||||
if (!await exists(script_url)) await new Deno.Command(update_url).output()
|
||||
const args = [script_url.pathname, ...args_]
|
||||
const process = new Deno.Command("python3", { args, stdout: "piped" }).spawn()
|
||||
const output = await process.output()
|
||||
return new TextDecoder().decode(output.stdout)
|
||||
}
|
||||
|
||||
Deno.test("test_run", async () => {
|
||||
console.log(await run("--help"))
|
||||
})
|
||||
|
||||
export async function check(content: string): Promise<Output> {
|
||||
const temp_file = "/tmp/gram_input"
|
||||
await Deno.writeTextFile(temp_file, content)
|
||||
const output = await run("--file", temp_file, "--with_spell_sugg", "--json")
|
||||
return output_schema().parse(JSON.parse(output))
|
||||
}
|
||||
|
||||
Deno.test("test_check", async () => {
|
||||
const value = await check("Très mal écri et un arbres.")
|
||||
// Contains weird characters that throws off equality.
|
||||
// deno-lint-ignore no-explicit-any
|
||||
delete (value.data[0].lGrammarErrors[0] as any).sMessage
|
||||
// Array with an inconsistent order.
|
||||
// deno-lint-ignore no-explicit-any
|
||||
delete (value.data[0].lSpellingErrors[0] as any).aSuggestions
|
||||
assertEquals(value, {
|
||||
"grammalecte": "2.1.1",
|
||||
"lang": "fr",
|
||||
"data": [
|
||||
{
|
||||
"iParagraph": 1,
|
||||
"lGrammarErrors": [{
|
||||
"nStart": 20,
|
||||
"nEnd": 26,
|
||||
"sLineId": "#24552",
|
||||
"sRuleId": "g3__gn_un_1m__b2_a3_1",
|
||||
"sType": "gn",
|
||||
"aColor": [64, 127, 191],
|
||||
"aSuggestions": ["arbre"],
|
||||
"URL": "",
|
||||
// deno-lint-ignore no-explicit-any
|
||||
} as any],
|
||||
"lSpellingErrors": [{
|
||||
"i": 3,
|
||||
"sType": "WORD",
|
||||
"sValue": "écri",
|
||||
"nStart": 9,
|
||||
"nEnd": 13,
|
||||
// deno-lint-ignore no-explicit-any
|
||||
} as any],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
21
src/main.ts
Executable file
21
src/main.ts
Executable file
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env -S deno run --allow-all
|
||||
|
||||
import { Hono } from "jsr:@hono/hono@4.8.4"
|
||||
import { request_schema } from "./common.ts"
|
||||
import { check } from "./lib/grammalecte.ts"
|
||||
|
||||
async function main() {
|
||||
const page = await Deno.readTextFile(new URL("page/index.html", import.meta.url))
|
||||
const js = await Deno.readTextFile(new URL("page/main.mjs", import.meta.url))
|
||||
const server = new Hono()
|
||||
server.get("/", (c) => c.html(page))
|
||||
server.get("/main.mjs", () => new Response(js, { headers: { "Content-Type": "text/javascript" } }))
|
||||
server.post("/check", async (c) => {
|
||||
const request = request_schema().parse(await c.req.json())
|
||||
const response = await check(request.text)
|
||||
return c.json(response)
|
||||
})
|
||||
Deno.serve(server.fetch)
|
||||
}
|
||||
|
||||
if (import.meta.main) await main()
|
6
src/page/gen
Executable file
6
src/page/gen
Executable file
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/bash
|
||||
set -e
|
||||
cd "$(dirname "$(realpath "$0")")"
|
||||
|
||||
|
||||
deno bundle --unstable-raw-imports --minify --platform=browser main.ts --output main.mjs
|
49
src/page/index.html
Normal file
49
src/page/index.html
Normal file
|
@ -0,0 +1,49 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sakura.css/css/sakura.css" type="text/css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura-dark.css" media="screen and (prefers-color-scheme: dark)" />
|
||||
<title>Document</title>
|
||||
<style>
|
||||
.tooltip_parent {
|
||||
position: relative;
|
||||
}
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background-color: #0008;
|
||||
backdrop-filter: blur(5px);
|
||||
left: 0;
|
||||
top: 1rem;
|
||||
}
|
||||
.tooltip_parent:hover .tooltip {
|
||||
visibility: visible;
|
||||
}
|
||||
.choice:hover {
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
color: black;
|
||||
}
|
||||
.description {
|
||||
background-color: black;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="padding: 0;">
|
||||
<header style="height: 10rem; display: grid; place-items: center left;">
|
||||
<h1>grammaire.barnulf.net</h1>
|
||||
</header>
|
||||
<main>
|
||||
<textarea id="input" style="height: calc((100vh - 30rem) / 2);" placeholder="Texte à corriger ..."></textarea>
|
||||
<pre id="fixes" style="min-height: calc((100vh - 30rem) / 2);"></pre>
|
||||
</main>
|
||||
<footer style="height: 10rem; display: grid; place-items: center left;">
|
||||
<p>Correcteur grammatical basé sur grammalecte.</p>
|
||||
</footer>
|
||||
<script type="module" src="./main.mjs"></script>
|
||||
</body>
|
||||
</html>
|
30
src/page/main.mjs
Normal file
30
src/page/main.mjs
Normal file
File diff suppressed because one or more lines are too long
100
src/page/main.ts
Normal file
100
src/page/main.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
/// <reference lib="dom" />
|
||||
|
||||
import { Output, output_schema, Request } from "../common.ts"
|
||||
import { Slicer } from "./slicer.ts"
|
||||
|
||||
const input_area = document.querySelector<HTMLTextAreaElement>("#input")!
|
||||
const fixes_area = document.querySelector<HTMLPreElement>("#fixes")!
|
||||
|
||||
function render(text: string, fixes: Output) {
|
||||
const slicer = new Slicer(text, (text: string) => {
|
||||
const element = document.createElement("span")
|
||||
element.innerText = text
|
||||
return element as HTMLElement
|
||||
})
|
||||
console.log(fixes.data)
|
||||
const paragraphs = text.split("\n")
|
||||
let i = 0
|
||||
let offset = 0
|
||||
for (const chunk of fixes.data) {
|
||||
const paragraph = paragraphs[i++]
|
||||
for (const error of chunk.lSpellingErrors) {
|
||||
tooltip(
|
||||
text,
|
||||
offset + error.nStart,
|
||||
offset + error.nEnd,
|
||||
slicer,
|
||||
"Mot inconnu : " + error.sValue,
|
||||
"#f004",
|
||||
error.aSuggestions,
|
||||
)
|
||||
}
|
||||
for (const error of chunk.lGrammarErrors) {
|
||||
tooltip(
|
||||
text,
|
||||
offset + error.nStart,
|
||||
offset + error.nEnd,
|
||||
slicer,
|
||||
error.sMessage,
|
||||
`rgba(${error.aColor.join(",")},0.25)`,
|
||||
error.aSuggestions,
|
||||
)
|
||||
}
|
||||
offset += paragraph.length + 1
|
||||
}
|
||||
fixes_area.innerHTML = ""
|
||||
for (const part of slicer.parts) {
|
||||
fixes_area.appendChild(part.content(part.text))
|
||||
}
|
||||
}
|
||||
|
||||
function tooltip(
|
||||
text: string,
|
||||
from: number,
|
||||
to: number,
|
||||
slicer: Slicer<(word: string) => Element>,
|
||||
description: string,
|
||||
color: string,
|
||||
suggestions: string[],
|
||||
) {
|
||||
slicer.slice(from, to, (word) => {
|
||||
const element = document.createElement("span")
|
||||
element.innerText = word
|
||||
element.style.backgroundColor = color
|
||||
element.className = "tooltip_parent"
|
||||
const hover_element = document.createElement("div")
|
||||
hover_element.className = "tooltip"
|
||||
const descr = document.createElement("div")
|
||||
descr.className = "description"
|
||||
descr.innerText = description
|
||||
hover_element.appendChild(descr)
|
||||
element.appendChild(hover_element)
|
||||
for (const suggestion of suggestions) {
|
||||
const choice = document.createElement("div")
|
||||
choice.className = "choice"
|
||||
choice.innerHTML = suggestion
|
||||
hover_element.appendChild(choice)
|
||||
choice.addEventListener("click", () => {
|
||||
input_area.value = text.slice(0, from) + suggestion + text.slice(to)
|
||||
update()
|
||||
})
|
||||
}
|
||||
return element
|
||||
})
|
||||
}
|
||||
|
||||
let last_text = ""
|
||||
async function update() {
|
||||
const text = input_area.value
|
||||
if (last_text === text) return
|
||||
const request_body: Request = { text }
|
||||
const result = await fetch("/check", { method: "POST", body: JSON.stringify(request_body) })
|
||||
const body = await result.json()
|
||||
const parsed = output_schema().parse(body)
|
||||
render(text, parsed)
|
||||
last_text = text
|
||||
}
|
||||
|
||||
input_area.addEventListener("change", update)
|
||||
setInterval(update, 1_000)
|
||||
update()
|
38
src/page/slicer.ts
Normal file
38
src/page/slicer.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
export class Slicer<T> {
|
||||
public readonly parts: Part<T>[]
|
||||
|
||||
public constructor(
|
||||
text: string,
|
||||
public readonly content: T,
|
||||
) {
|
||||
this.parts = [new Part(text, 0, text.length, content)]
|
||||
}
|
||||
|
||||
public slice(from: number, to: number, content: T) {
|
||||
const found_id = this.parts.findIndex((p) => (p.from <= from) && (p.to >= to))
|
||||
if (found_id === -1) return undefined
|
||||
const found = this.parts[found_id]
|
||||
const nodes = found.slice(from, to, content)
|
||||
this.parts.splice(found_id, 1, ...nodes)
|
||||
}
|
||||
}
|
||||
|
||||
class Part<T> {
|
||||
public constructor(
|
||||
public readonly text: string,
|
||||
public readonly from: number,
|
||||
public readonly to: number,
|
||||
public readonly content: T,
|
||||
) {}
|
||||
|
||||
public len() {
|
||||
return this.to - this.from
|
||||
}
|
||||
|
||||
public slice(from: number, to: number, content: T) {
|
||||
const before = new Part(this.text.slice(this.from - this.from, from - this.from), this.from, from, this.content)
|
||||
const middle = new Part(this.text.slice(from - this.from, to - this.from), from, to, content)
|
||||
const after = new Part(this.text.slice(to - this.from, this.to - this.from), to, this.to, this.content)
|
||||
return [before, middle, after].filter((p) => p.len() > 0)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue