From 376b946e7345be01fa6def3f78cd5e134a67f5b3 Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Sun, 12 Apr 2026 22:28:15 +0200 Subject: [PATCH] Implement AsciiDoc and TipTap conversion logic, including new Admonition node and CodeBlock extension; add DiffViewer and HistoryPanel components for document version comparison; introduce password hashing utility with SHA-256. --- ARCHITECTURE.md | 459 ++++++++++++++++++ ASCIIDOC_TIPTAP_CONVERSION.md | 351 ++++++++++++++ README.md | 165 +++++-- backend/internal/db/db.go | 7 + backend/internal/git/git.go | 66 ++- backend/internal/graph/schema.graphql | 12 + backend/internal/graph/server.go | 133 ++++- frontend/package-lock.json | 98 +++- frontend/package.json | 16 + frontend/src/App.vue | 8 +- frontend/src/bridge/asciidoc-bridge.ts | 295 +++++------ frontend/src/bridge/asciidoc-extensions.ts | 41 ++ .../src/components/editor/VisualEditor.vue | 208 +++++++- .../src/components/history/DiffViewer.vue | 354 ++++++++++++++ .../src/components/history/HistoryPanel.vue | 170 +++++++ frontend/src/components/layout/AppLayout.vue | 2 +- frontend/src/components/layout/Sidebar.vue | 144 +++++- .../src/components/wizard/SetupWizard.vue | 138 +++++- frontend/src/lib/crypto.ts | 14 + frontend/src/stores/app.ts | 6 +- frontend/src/views/AdminView.vue | 74 ++- frontend/src/views/DocumentView.vue | 329 +++++++++++-- 22 files changed, 2767 insertions(+), 323 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 ASCIIDOC_TIPTAP_CONVERSION.md create mode 100644 frontend/src/bridge/asciidoc-extensions.ts create mode 100644 frontend/src/components/history/DiffViewer.vue create mode 100644 frontend/src/components/history/HistoryPanel.vue create mode 100644 frontend/src/lib/crypto.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..55e50bb --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,459 @@ +# Archivum — Arkitektur och designdokumentation + +## Innehållsförteckning + +1. [Systemöversikt](#1-systemöversikt) +2. [Frontend](#2-frontend) +3. [Backend](#3-backend) +4. [Exekveringsflöden](#4-exekveringsflöden) +5. [Kodstruktur](#5-kodstruktur) +6. [Containerisering och driftsättning](#6-containerisering-och-driftsättning) + +--- + +## 1. Systemöversikt + +Archivum är en självhostad wiki/dokumenthanterare. Alla dokument lagras som AsciiDoc-filer (`.adoc`) på disk och versionshanteras automatiskt med Git. Frontenden är en SPA (Single-Page Application) som kommunicerar med backenden uteslutande via GraphQL. + +``` +Webbläsare + │ HTTPS / HTTP + ▼ +┌──────────────────────────────┐ +│ Go HTTP-server :4000 │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ /graphql │ │ SPA / │ │ +│ └────┬─────┘ └──────────┘ │ +│ │ │ +│ ┌────▼──────────────────┐ │ +│ │ GraphQL dispatcher │ │ +│ └─┬──────┬──────┬───────┘ │ +│ Auth Storage Git │ +│ (SQLite)(disk) (os/exec) │ +└──────────────────────────────┘ +``` + +### Teknologistack + +| Lager | Teknik | +|--------------|----------------------------------------------------------------| +| Frontend | Vue 3 (Composition API), Vite, TypeScript, Tailwind CSS | +| State | Pinia | +| Routing | Vue Router 4 | +| Editor | TipTap 2 (visuell), CodeMirror 6 (AsciiDoc-källa) | +| AsciiDoc | Asciidoctor.js (rendering i webbläsaren) | +| PWA | vite-plugin-pwa (Service Worker, manifest) | +| Backend | Go 1.22, HTTP-standardbibliotek | +| Databas | SQLite via modernc.org/sqlite (CGO-fri, multi-arch) | +| Autentisering| Lokala konton (bcrypt) + LDAP, JWT-liknande sessions-tokens | +| Versionshantering | Git via `os/exec` | +| Containerisering | Docker (multi-stage, multi-arch: amd64 + arm64) | + +--- + +## 2. Frontend + +### Paket och beroenden + +``` +frontend/ +├── src/ +│ ├── App.vue # Rotemponent — kontrollerar setup/login/app-läge +│ ├── main.ts # Appstart, monterar Vue + Pinia + Router +│ ├── lib/ +│ │ ├── gql.ts # Minimal GraphQL-klient (fetch-baserad) +│ │ └── crypto.ts # Lösenords-hashing (SHA-256 via Web Crypto API) +│ ├── stores/ +│ │ ├── app.ts # Global state: token, requiresSetup, login/logout +│ │ └── theme.ts # Mörkt/ljust läge, persistas i localStorage +│ ├── router/index.ts # SPA-routes +│ ├── views/ # Sidkomponenter +│ ├── components/ # Återanvändningsbara UI-komponenter +│ │ ├── editor/ # Visuell och källkodseditor +│ │ ├── layout/ # AppLayout, Sidebar, ThemeToggle +│ │ └── wizard/ # Installationsguide (SetupWizard) +│ ├── bridge/ +│ │ └── asciidoc-bridge.ts # AsciiDoc ↔ TipTap JSON-konvertering +│ └── assets/main.css # Tailwind-bas + CSS-variabler +``` + +### Routing + +| Sökväg | Komponent | Åtkomstkrav | +|---------------|------------------|---------------------| +| `/` | HomeView | Inloggad | +| `/doc/:slug` | DocumentView | Inloggad | +| `/admin` | AdminView | Inloggad, admin | +| `/setup` | SetupWizard | Ej konfigurerat | + +Routeskydd hanteras **inte** med router guards utan direkt i `App.vue` — se avsnitt 4.1. + +### GraphQL-klienten (`lib/gql.ts`) + +Alla API-anrop görs med en enkel `fetch`-wrapper. Klienten: +- Läser JWT-token från `localStorage` och skickar den som `Authorization: Bearer ` +- Kastar ett `Error` om svaret innehåller GraphQL-errors +- Hanterar automatisk utloggning vid `UNAUTHORIZED`-fel + +### Lösenordshantering (`lib/crypto.ts`) + +Lösenordet hashas på klienten **innan** det skickas till servern med: +``` +SHA-256(lowercase(username) + ":" + password) +``` +Web Crypto API (`crypto.subtle.digest`) används — ingen extern beroende. Username fungerar som domän-separator (salt) så att samma lösenord ger olika digest för olika konton. Backenden tar emot denna digest och lagrar `bcrypt(digest)`. + +### PWA + +Konfigureras i `vite.config.ts` via `vite-plugin-pwa`: +- Service Worker med `skipWaiting` + `clientsClaim` (ny SW aktiveras omedelbart) +- Cachar statiska assets (JS, CSS, fonts) +- HTML och `/graphql` går alltid till nätverket (`NetworkFirst`) +- `navigateFallback: null` — förhindrar att SW returnerar cachat HTML för routes + +--- + +## 3. Backend + +### Paket + +``` +backend/ +├── cmd/server/main.go # Startpunkt — laddar config, startar HTTP-server +├── internal/ +│ ├── config/config.go # Config-struktur, Load/Save, ErrRequireSetup +│ ├── auth/auth.go # Session-hantering, bcrypt, LDAP-bind +│ ├── db/db.go # SQLite-wrapper — users-tabell +│ ├── storage/storage.go # Filhantering — Read/Write/List .adoc-filer +│ ├── git/git.go # Git-wrapper via os/exec — Commit/Log/Diff +│ └── graph/ +│ ├── server.go # HTTP-handler + handgjord GraphQL-dispatcher +│ ├── resolver.go # Resolver-stub (plats för gqlgen-genererad kod) +│ └── schema.graphql # GraphQL-schema +``` + +### GraphQL-dispatcher + +Backenden använder **inte** gqlgen-genererad kod i produktion. `server.go` implementerar en handgjord dispatcher som parsar query-strängen med `strings.Contains` och router till respektive handler-funktion. Detta ger enkel deployment utan kodgenerering men kräver manuell underhåll av schema och handlers. + +### Autentisering och sessioner + +``` +Login-flöde: + 1. Klient: SHA-256(username:password) → skickas som "password" + 2. Server: bcrypt.CompareHashAndPassword(stored_hash, received_digest) + 3. Vid match: slumpmässig hex-token genereras och sparas i minnet + 4. Token returneras till klient, lagras i localStorage + 5. Alla efterföljande requests: Authorization: Bearer + +Session-storage: in-memory map[token]*Session (försvinner vid omstart) +Session-livstid: 7 dagar (konfigurerat i auth.go) +``` + +**Viktigt:** Sessions lagras enbart i minnet. Vid server-omstart måste alla klienter logga in igen. + +### Setup-detektion — `needsSetup` + +Backenden räknar ett system som oklonfigurerat (`REQUIRE_SETUP`) om **något** av följande är sant: + +1. `config.json` saknas eller är ofullständig (`cfg == nil`) +2. Databasen kunde inte öppnas (`database == nil`) +3. Databasen är tom — ingen adminanvändare har skapats (`!database.HasUsers()`) + +`db.HasUsers()` gör en enkel `SELECT COUNT(*) FROM users` och returnerar `true` om minst ett konto finns. Detta innebär att en nyskapad, tom SQLite-fil (som Go skapar vid `db.New()`) fortfarande triggar setup-läge. + +### DB-sökväg — `resolveDBPath()` + +Setup-handleren bestämmer DB-sökvägen dynamiskt för att fungera i både Docker och dev-miljö: + +``` +resolveDBPath(configPath): + ├─ Kan /data/db skapas/skrivas? → /data/db/archivum.db (Docker-volym) + └─ Annars → /archivum.db (dev-miljö) +``` + +### Konfiguration + +`config.json` hanteras av `internal/config`: +- `storage_path` — rotkatalog för .adoc-filer och Git-repo +- `db_path` — sökväg till SQLite-fil +- `jwt_secret` — används för session-token generation (ej JWT i klassisk mening) +- `listen_addr` — TCP-adress att lyssna på, t.ex. `:4000` +- `ldap` — valfri LDAP-konfiguration + +Om filen saknas returnerar `Load()` `ErrRequireSetup` — backenden startar i setup-läge. + +--- + +## 4. Exekveringsflöden + +### 4.1 Appstart och setup-detektion + +``` +Browser laddar / + │ + ▼ +App.vue onMounted() + │ + ├─ theme.init() (laddar tema från localStorage) + │ + ├─ app.checkStatus() → POST /graphql { systemStatus } + │ │ Backend: needsSetup = cfg==nil || db==nil || !db.HasUsers() + │ │ + │ ├─ svar: "REQUIRE_SETUP" + │ │ → app.requiresSetup = true + │ │ → visas: (renderas direkt, ej via router) + │ │ + │ └─ svar: "OK" + │ → app.requiresSetup = false + │ ├─ app.token finns: → visas: + │ └─ app.token saknas: → visas: + │ + └─ ready = true (spinner försvinner) +``` + +`SetupWizard`, `LoginView` och `AppLayout` renderas direkt i `App.vue` (inte via ``) för att undvika race conditions mellan `router.replace()` och Vue-renderingen. + +### 4.2 Installationsguiden (SetupWizard) + +``` +Steg 1: Ange storage-sökväg, admin-användare, lösenord +Steg 2: Valfri LDAP-konfiguration +Steg 3: Bekräftelse + +Vid submit: + 1. SHA-256(adminUser:adminPass) → hashedPass + 2. POST /graphql mutation Setup { setup(input: {..., adminPass: hashedPass}) } + ├─ Backend: resolveDBPath() → bestämmer db_path (Docker vs dev) + ├─ Backend: skapar SQLite-DB och admin-användare med bcrypt(hashedPass) ← FÖRST + ├─ Backend: sparar config.json ← SEDAN + └─ Backend: initierar storage-katalog och Git-repo + 3. app.requiresSetup = false + 4. app.login(adminUser, adminPass) → SHA-256 igen → POST /graphql mutation Login + 5. Redirect till / +``` + +Ordningen är viktig: om DB-skapandet eller user-insert misslyckas sparas **ingen** `config.json`. Nästa sidladdning ger återigen `REQUIRE_SETUP` och guiden visas på nytt. Tidigare sparades config först, vilket kunde lämna ett halvfärdigt tillstånd där backenden startade med config men utan adminanvändare. + +### 4.3 Inloggningsflöde + +``` +Användaren fyller i användarnamn + lösenord + │ + ▼ +app.login(username, password) + │ + ├─ SHA-256(lowercase(username):password) → hashed + │ + └─ POST /graphql mutation Login($u, $p: hashed) + │ + ▼ + handleLogin (server.go) + │ + ├─ db.GetUser(username) → lokal SQLite + │ ├─ hittad: bcrypt.Compare(stored, hashed) → OK/fel + │ └─ ej hittad + LDAP konfigurerat → ldapBind test + │ + ├─ Skapar slumpmässig session-token + └─ Returnerar token + │ + ▼ + Klient sparar token i localStorage + AppLayout renderas +``` + +### 4.4 Spara dokument + +``` +Användaren redigerar och klickar Spara + │ + ▼ +POST /graphql mutation SaveDocument + { slug, content (AsciiDoc), commitMessage, author } + │ + ▼ +handleSaveDocument (server.go) + │ + ├─ Kontrollerar session (Bearer token → Manager.Validate()) + ├─ storage.Write(slug, content) → skriver slug.adoc till disk + └─ git.Commit(slug.adoc, message, author, email) + │ + └─ os/exec: git add → git commit +``` + +### 4.5 Versionshistorik och diff + +``` +GET /graphql { history(slug) } + │ + └─ git.Log(slug.adoc) → os/exec: git log --format=... + → []LogEntry { Hash, Author, Date, Subject } + +GET /graphql { diff(slug, fromHash, toHash) } + │ + └─ git.Diff(slug.adoc, from, to) → os/exec: git diff + → unified diff-sträng +``` + +--- + +## 5. Kodstruktur + +### Fullständig katalogstruktur + +``` +Archivum/ +├── backend/ +│ ├── cmd/server/main.go # Entrypoint +│ ├── config.json # Lokal dev-konfiguration (gitignoreras ej — bör .gitignore:as i produktion) +│ ├── go.mod / go.sum +│ └── internal/ +│ ├── auth/auth.go # Session, bcrypt, LDAP +│ ├── config/config.go # Config-struktur och fil-I/O +│ ├── db/db.go # SQLite users-tabell +│ ├── git/git.go # Git-wrapper +│ ├── storage/storage.go # .adoc fil-I/O +│ └── graph/ +│ ├── schema.graphql # GraphQL-schema (source of truth) +│ ├── resolver.go # Resolver-stub +│ └── server.go # HTTP-server + dispatcher + alla handlers +│ +├── frontend/ +│ ├── index.html +│ ├── vite.config.ts # Vite + PWA + dev-proxy konfiguration +│ ├── tailwind.config.js +│ ├── package.json +│ └── src/ +│ ├── App.vue # Rotkomponent med setup/login/app-routing +│ ├── main.ts +│ ├── lib/ +│ │ ├── gql.ts # GraphQL fetch-klient +│ │ └── crypto.ts # SHA-256 lösenordshashing +│ ├── stores/ +│ │ ├── app.ts # Global app-state (Pinia) +│ │ └── theme.ts # Temahantering (Pinia) +│ ├── router/index.ts +│ ├── views/ +│ │ ├── HomeView.vue # Dokumentlista +│ │ ├── DocumentView.vue # Visa/redigera dokument +│ │ ├── AdminView.vue # Administrationspanel +│ │ └── LoginView.vue # Inloggningsformulär +│ ├── components/ +│ │ ├── editor/ +│ │ │ ├── VisualEditor.vue # TipTap WYSIWYG-editor +│ │ │ └── SourceEditor.vue # CodeMirror AsciiDoc-källeditor +│ │ ├── layout/ +│ │ │ ├── AppLayout.vue # Huvudlayout med sidebar +│ │ │ ├── Sidebar.vue # Dokumentnavigering +│ │ │ └── ThemeToggle.vue # Ljust/mörkt läge-knapp +│ │ └── wizard/ +│ │ └── SetupWizard.vue # Installationsguide (3 steg) +│ ├── bridge/ +│ │ └── asciidoc-bridge.ts # AsciiDoc ↔ TipTap JSON +│ └── assets/main.css +│ +├── docker/ +│ ├── Dockerfile # Multi-stage build (frontend → backend → runtime) +│ ├── entrypoint.sh # PUID/PGID-hantering, katalogskapande +│ ├── docker-compose.yml # Dev/standard deploy +│ └── docker-compose.prod.yml # Produktionsinställningar +│ +└── ARCHITECTURE.md # Detta dokument +``` + +### Nyckelgränssnitt och datakontrakt + +**GraphQL-schema (urval):** +```graphql +type Query { + systemStatus: SystemStatus! # OK | REQUIRE_SETUP + documents(prefix: String): [DocumentMeta!]! + document(slug: String!): Document + history(slug: String!): [CommitEntry!]! + diff(slug: String!, fromHash: String!, toHash: String!): String! +} + +type Mutation { + setup(input: SetupInput!): Boolean! + login(username: String!, password: String!): String! # returnerar session-token + logout: Boolean! + saveDocument(input: SaveDocumentInput!): Document! + deleteDocument(slug: String!): Boolean! +} +``` + +--- + +## 6. Containerisering och driftsättning + +### Dockerfile (multi-stage) + +``` +Stage 1 (frontend-builder) — node:20-alpine + npm install && npm run build → /app/frontend/dist + +Stage 2 (backend-builder) — golang:1.22-alpine + CGO_ENABLED=0 go build → /archivum (statisk binär) + +Stage 3 (runtime) — alpine:3.20 + Installerar: su-exec, git + Kopierar: dist/ → /srv/archivum/ui + archivum → /usr/local/bin/archivum + entrypoint.sh + Volumes: /config, /data + Port: 4000 + Entrypoint: entrypoint.sh +``` + +### Volymer och kataloger + +| Volym | Innehåll | Env-variabel | +|-----------|---------------------------------------------------|----------------| +| `/config` | `config.json` | `DOCKER_PATH` | +| `/data` | `wiki/` (AsciiDoc + Git-repo), `db/archivum.db` | — | + +### Miljövariabler + +| Variabel | Standard | Beskrivning | +|---------------|-------------------|------------------------------------------| +| `DOCKER_PATH` | — | Host-sökväg som monteras som `/config` | +| `UI_DIR` | `/srv/archivum/ui`| Sökväg till kompilerad frontend | +| `PUID` | `1000` | Filsystemsägare (UID) | +| `PGID` | `1000` | Filsystemsägare (GID) | +| `TZ` | `Europe/Stockholm`| Tidszon | +| `HOST_PORT` | `8080` | Extern port i docker-compose | + +### Multi-arch build + +Bilden stöder `linux/amd64` (server/desktop) och `linux/arm64` (Raspberry Pi 5): +```bash +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t registry:5000/archivum:latest \ + -f docker/Dockerfile --push . +``` + +### entrypoint.sh — PUID/PGID-hantering + +Containern startar som `root`. `entrypoint.sh`: +1. Skapar `/config`, `/data/wiki`, `/data/db` om de saknas +2. Ändrar ägare på volymerna till `PUID:PGID` +3. Kör applikationen som `PUID:PGID` via `su-exec` + +### docker-compose.yml (minimal .env) + +```env +DOCKER_PATH=/opt/archivum # Host-sökväg — config/ och data/ skapas här +HOST_PORT=8080 +PUID=1000 +PGID=1000 +TZ=Europe/Stockholm +``` + +### Uppgraderingsförfarande + +1. Bygg/hämta ny image +2. `docker compose down` +3. `docker compose up -d` + +Inga databas-migreringar behövs i de flesta uppgraderingar; SQLite-schemat är bakåtkompatibelt (`CREATE TABLE IF NOT EXISTS`). diff --git a/ASCIIDOC_TIPTAP_CONVERSION.md b/ASCIIDOC_TIPTAP_CONVERSION.md new file mode 100644 index 0000000..a54dbe9 --- /dev/null +++ b/ASCIIDOC_TIPTAP_CONVERSION.md @@ -0,0 +1,351 @@ +# Konvertering mellan AsciiDoc och TipTap: Typspecifikation + +Detta dokument beskriver i detalj hur datamodellen mappas mellan **TipTap JSON** och **AsciiDoc**. Dokumentet fungerar som en specifikation för utvecklare som ska implementera konverteringslogik, baserat på referenslogiken från `tiptap-to-asciidoc`. + +**Gällande versioner:** +* **TipTap:** v2 +* **AsciiDoc:** Asciidoctor (Asciidoctor.js / källkodsstandard) + +--- + +## Översikt av datamodeller + +* **TipTap JSON:** Ett träd av _Nodes_ (blockelement som stycken, rubriker, listor) och _Marks_ (inline-formatering som fetstil, kursivt, länkar). Roten i trädet är en nod av typen `doc` som innehåller en array av block-noder i `content`. +* **AsciiDoc:** Textbaserad markup som förlitar sig på radbrytningar och prefix/suffix (t.ex. `==`, `*`, `_`). + +Vid konvertering från TipTap till AsciiDoc traverseras JSON-trädet uppifrån och ner. +Vid konvertering från AsciiDoc till TipTap behöver AsciiDoc-mjukvara generellt konvertera texten till HTML, varefter TipTaps inbyggda parser (`@tiptap/html` -> `generateJSON`) nyttjas för att omvandla HTML till TipTap JSON. + +--- + +## Block-noder (Nodes) + +### 1. Document (Rot-nod) + +**TipTap JSON:** +```json +{ + "type": "doc", + "content": [ + ... // Andra block-noder + ] +} +``` + +**AsciiDoc:** +Inget specifikt syntax, detta representerar hela dokumentfilen. Block-noderna under `content` renderas sekventiellt separerade med tomma rader (två radbrytningar `\n\n` mellan blockelement rekommenderas generellt). + +--- + +### 2. Heading (Rubriker) + +Rubriknivåer definieras med attributet `level`. + +**TipTap JSON:** +```json +{ + "type": "heading", + "attrs": { + "level": 2 + }, + "content": [ + { "type": "text", "text": "Min Rubrik" } + ] +} +``` + +**AsciiDoc:** +Antalet likhetstecken (`=`) motsvarar värdet i `level`. Exempel för `level: 2`: +```asciidoc +== Min Rubrik +``` + +--- + +### 3. Paragraph (Stycken och Tomma rader) + +Ett stycke översätts normalt rätt upp och ner. En tom rad i TipTap (ett tomt stycke) representeras via AsciiDocs specialattribut `{blank}` vilket skapar en tom paragraf som parsas tillbaka som ett tomt stycke. + +**TipTap JSON (Vanligt stycke):** +```json +{ + "type": "paragraph", + "content": [ + { "type": "text", "text": "Ett vanligt stycke text." } + ] +} +``` + +**AsciiDoc (Vanligt stycke):** +```asciidoc +Ett vanligt stycke text. +``` + +**TipTap JSON (Tomt stycke / Tom rad):** +```json +{ + "type": "paragraph" +} +``` + +**AsciiDoc (Tomt stycke / Tom rad):** +```asciidoc +{blank} +``` + +--- + +### 4. Code Block (Kodblock) + +**TipTap JSON:** +```json +{ + "type": "codeBlock", + "attrs": { + "language": "javascript" + }, + "content": [ + { "type": "text", "text": "console.log('Hej');" } + ] +} +``` + +**AsciiDoc:** +```asciidoc +[source,javascript] +---- +console.log('Hej'); +---- +``` + +--- + +### 5. Blockquote (Citatblock) + +**TipTap JSON:** +```json +{ + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Detta är ett citat." } + ] + } + ] +} +``` + +**AsciiDoc:** +```asciidoc +____ +Detta är ett citat. +____ +``` + +--- + +### 6. Horizontal Rule (Avdelare) + +**TipTap JSON:** +```json +{ + "type": "horizontalRule" +} +``` + +**AsciiDoc:** +```asciidoc +''' +``` + +--- + +### 7. Listor (Bullet List / Ordered List) + +**TipTap JSON (Oordnad / Bullet List):** +```json +{ + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{ "type": "text", "text": "Punkt 1" }] + } + ] + } + ] +} +``` + +**AsciiDoc (Oordnad):** +```asciidoc +* Punkt 1 +``` + +**TipTap JSON (Ordnad / Ordered List):** +Samma men `type` är `"orderedList"`. + +**AsciiDoc (Ordnad):** +```asciidoc +. Punkt 1 +. Punkt 2 +.. Underpunkt 2.1 +``` + +--- + +## Inline-formatering (Marks) + +### Hard Break +**TipTap JSON:** +```json +{ "type": "hardBreak" } +``` +**AsciiDoc:** +```asciidoc + + +``` + +### Bold +**TipTap JSON:** +```json +{ "type": "text", "text": "viktig", "marks": [{ "type": "bold" }] } +``` +**AsciiDoc:** +```asciidoc +*viktig* +``` + +### Italic +**TipTap JSON:** +```json +{ "type": "text", "text": "speciell", "marks": [{ "type": "italic" }] } +``` +**AsciiDoc:** +```asciidoc +_speciell_ +``` + +### Strike +**TipTap JSON:** +```json +{ "type": "text", "text": "gammal", "marks": [{ "type": "strike" }] } +``` +**AsciiDoc:** +```asciidoc +[line-through]#gammal# +``` + +### Code +**TipTap JSON:** +```json +{ "type": "text", "text": "const x = 1;", "marks": [{ "type": "code" }] } +``` +**AsciiDoc:** +```asciidoc +`const x = 1;` +``` + +### Link +**TipTap JSON:** +```json +{ "type": "text", "text": "Klicka här", "marks": [{ "type": "link", "attrs": { "href": "https://example.com" } }] } +``` +**AsciiDoc:** +```asciidoc +https://example.com[Klicka här] +``` + +--- + +### 8. Länkar (Links) + +Explicita attributslänkar i AsciiDoc. + +**TipTap JSON:** +```json +{ + "type": "text", + "text": "platsöversikten", + "marks": [ + { + "type": "link", + "attrs": { + "href": "index" + } + } + ] +} +``` + +**AsciiDoc:** +```asciidoc +link:index[platsöversikten] +``` +*(Notera att http/https-länkar kan använda `https://exempel.com[text]`, medan interna/släktlänkar primärt använder `link:path[text]` i AsciiDoc).* + +--- + +### 9. Admonitions (Varningar, Tips, Notiser) + +Informationsblock som sticker ut från mängden. För att TipTap inte ska tappa bort detta (vilket ofta sker om man låter standard HTML-tabeller importeras), representeras detta som en egen anpassad (custom) nod-typ i JSON, `admonition`. + +**TipTap JSON:** +```json +{ + "type": "admonition", + "attrs": { + "type": "tip" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { "type": "text", "text": "Spelarna känner till detta..." } + ] + } + ] +} +``` + +**AsciiDoc (Förenklad ettklyftig admonition):** +```asciidoc +[TIP] +==== +Spelarna känner till detta... +==== +``` +*(Eller enraders som `TIP: Spelarna...` - men blockformat är mer robust och generikt).* + +--- + +### 10. Code Block - Språk-attribut (Syntax Highlighting) + +För att kodblocket ska förstå och färgkoda språket korrekt (till exempel `yaml`), paraserar koden `attrs.language` i TipTap JSON med `language` till `source`-parametern i AsciiDoc. + +**TipTap JSON:** +```json +{ + "type": "codeBlock", + "attrs": { + "language": "yaml" + }, + "content": [ + { "type": "text", "text": "services:\n archivum:" } + ] +} +``` + +**AsciiDoc:** +```asciidoc +[source,yaml] +---- +services: + archivum: +---- +``` diff --git a/README.md b/README.md index 6012a6e..c5acbde 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,136 @@ # Archivum -A high-performance, production-ready wiki system optimized for Desktop and Mobile, designed to run on a Raspberry Pi 5 (ARM64) or any AMD64 host. +Archivum är ett självhostat wiki- och dokumenthanteringssystem. Det är tänkt att användas för allt från enkla anteckningar och teknisk dokumentation till världsbyggande inför rollspelskampanjer. Alla dokument lagras som **AsciiDoc**-filer (`.adoc`) på disk, vilket innebär att du kan läsa och redigera dem direkt med valfritt predikat — utan att öppna webbgränssnittet. Applikationen renderar dokumenten till HTML i webbläsaren och hanterar automatisk **versionshantering via Git**, så att varje sparning skapar ett commit och alla ändringar kan rullas tillbaka. -## Tech Stack -- **Backend:** Go (Golang) + GraphQL (gqlgen) + SQLite (modernc.org/sqlite). -- **Frontend:** Vue 3 (Composition API) + Vite + Tailwind CSS. -- **PWA:** Vite-plugin-pwa for mobile installation and offline caching. -- **Version Control:** Git (Backend executes Git commands on the storage path). -- **Editor:** TipTap (Visual) and CodeMirror (Manual AsciiDoc). -- **Auth:** LDAP + JWT with in-memory session tracking. +> Se [ARCHITECTURE.md](ARCHITECTURE.md) för detaljerad dokumentation om arkitektur, exekveringsflöden och komponentstruktur. -## Core Architectural Flow +--- -### 1. Configuration & First-Run Setup -- **Config Management:** Separate `config.json` (Backend) and `settings.json` (Frontend). Paths defined via Docker environment variables. -- **Initial State:** If configs are missing/empty, API signals `REQUIRE_SETUP`. Frontend triggers a "Setup Wizard" for LDAP, Admin user, and Storage paths. +## Användningsområden -### 2. Mobile & PWA Optimization -- **Responsive Design:** Mobile-first UI using Tailwind. The sidebar (tree view) should become a slide-over menu on mobile. -- **Read-Optimized:** Documents must be perfectly rendered for small screens with adjustable font sizes. -- **PWA Features:** Manifest and Service Worker for "Add to Home Screen" support, fast loading, and basic offline viewing of cached documents. +- **Anteckningar** — snabbanteckningar och personliga kunskapsbaser +- **Wiki** — team-wiki med dokumentträd och historik +- **Teknisk dokumentation** — API-dokumentation, systembeskrivningar, runbooks +- **Rollspelskampanjer** — världsbyggande, NPC-register, kartor och händelseloggar -### 3. Git-Storage & History -- **Save Operation:** Receive AsciiDoc string + Commit Message -> Write to disk -> Git Commit with user as author. -- **History & Diff:** GraphQL queries for commit logs and unified diffs. +--- -### 4. The Visual Transformer (Round-trip) -- TypeScript "Bridge" using **Asciidoctor.js AST**: - - `toTipTap(asciidoc: string): JSON` - - `fromTipTap(json: JSON): string` +## Krav -## Detailed Requirements +### För att köra med Docker (rekommenderat) -### Backend (Go) -- **CGO-Free:** Pure Go SQLite for multi-arch support (ARM64/AMD64). -- **Git:** Manage repo via `os/exec` or `go-git`. -- **Auth:** LDAP + Salted Bearer tokens. +- [Docker](https://docs.docker.com/get-docker/) ≥ 24 +- [Docker Compose](https://docs.docker.com/compose/) v2 -### Frontend (Vue 3) -- **Hybrid Editor:** Seamless toggle between Visual (TipTap) and Source (CodeMirror). -- **Mobile UI:** Collapsible navigation and touch-friendly buttons. -- **Setup Wizard:** Dedicated route for initial configuration. +### För lokal utveckling -### Docker & Deployment -- **Multi-Stage Dockerfile:** Multi-arch support. -- **Volumes:** Handle `${DOCKER_PATH}/config` and `${DOCKER_PATH}/data`. -- **Permissions:** Respect PUID/PGID for file system access. +- [Go](https://go.dev/dl/) ≥ 1.22 +- [Node.js](https://nodejs.org/) ≥ 20 + npm +- [Git](https://git-scm.com/) (används av backenden för versionshantering) + +--- + +## Snabbstart med Docker Compose + +**1. Skapa en `.env`-fil** i mappen `docker/` (eller i roten): + +```env +DOCKER_PATH=/opt/archivum # Host-katalog där config/ och data/ skapas +HOST_PORT=8080 +PUID=1000 +PGID=1000 +TZ=Europe/Stockholm +``` + +**2. Starta:** + +```bash +docker compose -f docker/docker-compose.yml up -d +``` + +**3. Öppna** `http://localhost:8080` i webbläsaren. Installationsguiden startar automatiskt vid första körningen. + +--- + +## Lokal utveckling + +### Backend + +```bash +cd backend +go run ./cmd/server +# Servern lyssnar på :4000 +# config.json i backend/ används som konfiguration +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +# Dev-server på :5173 med proxy till :4000 +``` + +Öppna `http://localhost:5173`. API-anrop proxyas automatiskt till backend-servern. + +### Bygga frontend för produktion + +```bash +cd frontend +npm run build +# Utdata i frontend/dist/ +``` + +--- + +## Konfiguration + +Konfigurationen lagras i `config.json`. Filen skapas automatiskt av installationsguiden. + +| Fält | Beskrivning | +|----------------|-----------------------------------------------------| +| `storage_path` | Sökväg till katalogen där .adoc-filer och Git-repot lagras | +| `db_path` | Sökväg till SQLite-databasen (användarkonton) | +| `jwt_secret` | Hemlig nyckel för session-tokens | +| `listen_addr` | TCP-adress att lyssna på, t.ex. `:4000` | +| `ldap` | Valfri LDAP-konfiguration för företagsinloggning | + +--- + +## Dokumentformat + +Alla dokument skrivs i [AsciiDoc](https://asciidoc.org/). Exempeldokument: + +```asciidoc += Mitt dokument +:author: Anna Andersson +:date: 2026-04-12 + +== Introduktion + +Det här är ett *fetstilt* stycke med en https://example.com[länk]. + +== Kodexempel + +[source,go] +---- +fmt.Println("Hello, Archivum!") +---- +``` + +Filen sparas som `mitt-dokument.adoc` i `storage_path` och versionshanteras automatiskt. + +--- + +## Docker: Multi-arch build + +För att bygga och pusha en image som stöder både AMD64 och ARM64 (Raspberry Pi): + +```bash +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t ditt-registry/archivum:latest \ + -f docker/Dockerfile --push . +``` -## Deliverables -1. Full project structure. -2. PWA configuration (`vite-plugin-pwa`) and responsive layout components. -3. The TypeScript Bridge for AsciiDoc <-> TipTap conversion. -4. Multi-arch Dockerfile and Docker Compose template. -5. README.md with setup guide and JSON schema. diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 3a5eb3e..104ac68 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -57,6 +57,13 @@ func (d *DB) init() error { return err } +// HasUsers returns true if at least one user account exists. +func (d *DB) HasUsers() bool { + var n int + d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) + return n > 0 +} + // CreateUser inserts a new user. Returns ErrUserExists if the username is taken. func (d *DB) CreateUser(username, passHash, role string) error { _, err := d.sql.Exec( diff --git a/backend/internal/git/git.go b/backend/internal/git/git.go index fae7a7a..ac5c4ad 100644 --- a/backend/internal/git/git.go +++ b/backend/internal/git/git.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" ) @@ -49,28 +50,43 @@ func (r *Repo) Commit(path, content, message, author, email string) error { return err } -// Log returns the commit history for a file. +// Log returns the commit history for a file, including added/removed line counts. func (r *Repo) Log(path string) ([]LogEntry, error) { - out, err := r.run("log", "--format=%H|%an|%ae|%ai|%s", "--", path) + out, err := r.run("log", "--format=COMMIT|%H|%an|%ae|%ai|%s", "--numstat", "--", path) if err != nil { return nil, err } var entries []LogEntry + var current *LogEntry for _, line := range strings.Split(strings.TrimSpace(out), "\n") { - if line == "" { - continue - } - parts := strings.SplitN(line, "|", 5) - if len(parts) == 5 { - entries = append(entries, LogEntry{ - Hash: parts[0], - Author: parts[1], - Email: parts[2], - Date: parts[3], - Subject: parts[4], - }) + if strings.HasPrefix(line, "COMMIT|") { + if current != nil { + entries = append(entries, *current) + } + parts := strings.SplitN(strings.TrimPrefix(line, "COMMIT|"), "|", 5) + if len(parts) == 5 { + current = &LogEntry{ + Hash: parts[0], + Author: parts[1], + Email: parts[2], + Date: parts[3], + Subject: parts[4], + } + } + } else if current != nil && strings.Contains(line, "\t") { + // numstat line: "added\tremoved\tfilename" + fields := strings.SplitN(line, "\t", 3) + if len(fields) == 3 { + added, _ := strconv.Atoi(fields[0]) + removed, _ := strconv.Atoi(fields[1]) + current.Added += added + current.Removed += removed + } } } + if current != nil { + entries = append(entries, *current) + } return entries, nil } @@ -85,12 +101,34 @@ func (r *Repo) Show(hash, path string) (string, error) { return r.run("show", fmt.Sprintf("%s:%s", hash, path)) } +// HasUncommitted returns true if the working tree contains untracked or +// modified files that have not yet been committed. +func (r *Repo) HasUncommitted() bool { + out, err := r.run("status", "--porcelain") + return err == nil && strings.TrimSpace(out) != "" +} + +// CommitAll stages every file in the repo root and creates a commit. +func (r *Repo) CommitAll(message, author, email string) error { + if _, err := r.run("add", "-A"); err != nil { + return err + } + _, err := r.run( + "-c", fmt.Sprintf("user.name=%s", author), + "-c", fmt.Sprintf("user.email=%s", email), + "commit", "-m", message, + ) + return err +} + type LogEntry struct { Hash string Author string Email string Date string Subject string + Added int + Removed int } func writeFile(path, content string) error { diff --git a/backend/internal/graph/schema.graphql b/backend/internal/graph/schema.graphql index 0c7b7e7..09c04c4 100644 --- a/backend/internal/graph/schema.graphql +++ b/backend/internal/graph/schema.graphql @@ -22,6 +22,9 @@ type Query { # Raw content of a document at a specific commit. documentAtCommit(slug: String!, hash: String!): String! + + # Whether the current storage path has uncommitted files. + repoStatus: RepoStatus! } type Mutation { @@ -51,6 +54,9 @@ type Mutation { # Change the current user's password. changePassword(old: String!, new: String!): Boolean! + + # Commit all uncommitted files in the storage path. + initCommit(message: String!): Boolean! } # ── Types ────────────────────────────────────────────────────────────────────── @@ -100,6 +106,12 @@ type CommitEntry { email: String! date: String! subject: String! + added: Int! + removed: Int! +} + +type RepoStatus { + hasUncommitted: Boolean! } # ── Inputs ───────────────────────────────────────────────────────────────────── diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index c85c6db..8927428 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -64,6 +64,11 @@ func (s *Server) initRuntime(cfg *config.Config) { } var d *db.DB + if dir := filepath.Dir(cfg.DBPath); dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + log.Printf("[db] failed to create directory %s: %v", dir, err) + } + } if database, err := db.New(cfg.DBPath); err != nil { log.Printf("[db] failed to open at %s: %v", cfg.DBPath, err) } else { @@ -127,8 +132,11 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) { s.mu.RLock() cfg := s.cfg store := s.store + database := s.database s.mu.RUnlock() + needsSetup := cfg == nil || database == nil || !database.HasUsers() + q := req.Query switch { @@ -137,7 +145,7 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) { s.handleSetup(w, req) case strings.Contains(q, "systemStatus"): - if cfg == nil { + if needsSetup { writeJSON(w, `{"data":{"systemStatus":"REQUIRE_SETUP"}}`) } else { writeJSON(w, `{"data":{"systemStatus":"OK"}}`) @@ -155,7 +163,7 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) { // ── All others require initialised config ───────────────────────────────── default: - if cfg == nil { + if needsSetup { writeGQLError(w, "REQUIRE_SETUP") return } @@ -201,17 +209,23 @@ func (s *Server) dispatchAuthenticated( case strings.Contains(q, "config"): s.handleConfig(w, sess) - case strings.Contains(q, "history"): - s.handleHistory(w, req, sess) + case strings.Contains(q, "repoStatus"): + s.handleRepoStatus(w, sess) - case strings.Contains(q, "diff"): - s.handleDiff(w, req, sess) + case strings.Contains(q, "initCommit"): + s.handleInitCommit(w, req, sess) - case strings.Contains(q, "documentAtCommit"): - s.handleDocumentAtCommit(w, req, sess) + case strings.Contains(q, "history"): + s.handleHistory(w, req, sess) - case strings.Contains(q, "documents") || strings.Contains(q, "document"): - s.handleDocuments(w, req, sess, store) + case strings.Contains(q, "diff"): + s.handleDiff(w, req, sess) + + case strings.Contains(q, "documentAtCommit"): + s.handleDocumentAtCommit(w, req, sess) + + case strings.Contains(q, "documents") || strings.Contains(q, "document"): + s.handleDocuments(w, req, sess, store) default: writeJSON(w, `{"data":{"systemStatus":"OK"}}`) @@ -826,6 +840,63 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) { }) } +func (s *Server) handleRepoStatus(w http.ResponseWriter, sess *auth.Session) { + if sess == nil { + writeGQLError(w, "UNAUTHORIZED") + return + } + + s.mu.RLock() + repo := s.gitRepo + s.mu.RUnlock() + + hasUncommitted := repo != nil && repo.HasUncommitted() + writeJSONObj(w, map[string]interface{}{ + "data": map[string]interface{}{ + "repoStatus": map[string]interface{}{ + "hasUncommitted": hasUncommitted, + }, + }, + }) +} + +func (s *Server) handleInitCommit(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if sess == nil { + writeGQLError(w, "UNAUTHORIZED") + return + } + + message, _ := req.Variables["message"].(string) + if message == "" { + message = "Initial commit" + } + + s.mu.RLock() + repo := s.gitRepo + s.mu.RUnlock() + + if repo == nil { + writeGQLError(w, "storage not initialised") + return + } + + if !repo.HasUncommitted() { + // Nothing to commit — return success without error. + writeJSON(w, `{"data":{"initCommit":true}}`) + return + } + + email := sess.Username + "@archivum" + if err := repo.CommitAll(message, sess.Username, email); err != nil { + log.Printf("[git] initCommit failed for %s: %v", sess.Username, err) + writeGQLError(w, fmt.Sprintf("commit failed: %v", err)) + return + } + + log.Printf("[git] initCommit by %s: %q", sess.Username, message) + writeJSON(w, `{"data":{"initCommit":true}}`) +} + func (s *Server) handleUpdateStoragePath(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { log.Printf("[unauth] session is nil") @@ -886,7 +957,7 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { cfg := &config.Config{ StoragePath: storagePath, - DBPath: "/data/db/archivum.db", + DBPath: resolveDBPath(s.configPath), JWTSecret: jwtSecret, ListenAddr: ":4000", } @@ -905,19 +976,8 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { } } - if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil { - writeGQLError(w, fmt.Sprintf("could not create config directory: %v", err)) - return - } - - if err := config.Save(s.configPath, cfg); err != nil { - writeGQLError(w, fmt.Sprintf("failed to save config: %v", err)) - return - } - - log.Printf("[setup] config written to %s", s.configPath) - - // Open database and create admin user. + // Open database and create admin user BEFORE saving config, + // so a failed DB creation does not leave a broken config.json on disk. if err := os.MkdirAll(dirOf(cfg.DBPath), 0755); err != nil { writeGQLError(w, fmt.Sprintf("could not create db directory: %v", err)) return @@ -942,6 +1002,18 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { log.Printf("[setup] admin user %q created", adminUser) + if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil { + writeGQLError(w, fmt.Sprintf("could not create config directory: %v", err)) + return + } + + if err := config.Save(s.configPath, cfg); err != nil { + writeGQLError(w, fmt.Sprintf("failed to save config: %v", err)) + return + } + + log.Printf("[setup] config written to %s", s.configPath) + store, err := storage.New(storagePath) if err != nil { log.Printf("[setup] failed to open storage at %s: %v", storagePath, err) @@ -1144,6 +1216,17 @@ func dirOf(path string) string { return path[:idx] } +// resolveDBPath returns the best DB path given the config file location. +// Prefers /data/db/archivum.db (Docker volume) if /data is writable; +// otherwise places the DB next to the config file. +func resolveDBPath(configPath string) string { + const dockerDB = "/data/db/archivum.db" + if err := os.MkdirAll("/data/db", 0755); err == nil { + return dockerDB + } + return filepath.Join(dirOf(configPath), "archivum.db") +} + func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { @@ -1174,6 +1257,8 @@ func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth "email": entry.Email, "date": entry.Date, "subject": entry.Subject, + "added": entry.Added, + "removed": entry.Removed, }) } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "history": out } }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 953823b..4310726 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,22 @@ "version": "0.1.0", "dependencies": { "@tiptap/core": "^2.4.0", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/html": "^2.27.2", "@tiptap/pm": "^2.4.0", "@tiptap/starter-kit": "^2.4.0", "@tiptap/vue-3": "^2.4.0", @@ -3068,6 +3084,23 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-link": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", + "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/@tiptap/extension-list-item": { "version": "2.27.2", "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", @@ -3146,6 +3179,23 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/html": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-2.27.2.tgz", + "integrity": "sha512-WiZgAvFjUprjyAczxAHLN9k++w7klwsCJ7EJDljtW/QXQ476Q0GqNtaHG4WRuW25cBH7c7AWZFptALeak2OE4Q==", + "license": "MIT", + "dependencies": { + "zeed-dom": "^0.15.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/@tiptap/pm": { "version": "2.27.2", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", @@ -4155,6 +4205,18 @@ "node": ">=8" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4748,7 +4810,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5775,6 +5836,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -8863,6 +8930,35 @@ "engines": { "node": ">=12" } + }, + "node_modules/zeed-dom": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/zeed-dom/-/zeed-dom-0.15.1.tgz", + "integrity": "sha512-dtZ0aQSFyZmoJS0m06/xBN1SazUBPL5HpzlAcs/KcRW0rzadYw12deQBjeMhGKMMeGEp7bA9vmikMLaO4exBcg==", + "license": "MIT", + "dependencies": { + "css-what": "^6.1.0", + "entities": "^5.0.0" + }, + "engines": { + "node": ">=14.13.1" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/holtwick" + } + }, + "node_modules/zeed-dom/node_modules/entities": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-5.0.0.tgz", + "integrity": "sha512-BeJFvFRJddxobhvEdm5GqHzRV/X+ACeuw0/BuuxsCh1EUZcAIz8+kYmBp/LrQuloy6K1f3a0M7+IhmZ7QnkISA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } } } } diff --git a/frontend/package.json b/frontend/package.json index cddb272..a8235c2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,22 @@ }, "dependencies": { "@tiptap/core": "^2.4.0", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/html": "^2.27.2", "@tiptap/pm": "^2.4.0", "@tiptap/starter-kit": "^2.4.0", "@tiptap/vue-3": "^2.4.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1d02fd0..2426442 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,22 +1,18 @@ @@ -37,7 +33,7 @@ onMounted(async () => { diff --git a/frontend/src/bridge/asciidoc-bridge.ts b/frontend/src/bridge/asciidoc-bridge.ts index b05f671..a5d3ca5 100644 --- a/frontend/src/bridge/asciidoc-bridge.ts +++ b/frontend/src/bridge/asciidoc-bridge.ts @@ -1,164 +1,179 @@ -/** - * AsciiDoc ↔ TipTap Bridge - * - * Converts between raw AsciiDoc strings and TipTap/ProseMirror JSON documents. - * Uses Asciidoctor.js to parse AsciiDoc into an AST, then maps nodes to - * ProseMirror node types understood by TipTap's StarterKit. - * - * Round-trip guarantee: fromTipTap(toTipTap(adoc)) should be semantically - * equivalent to the original adoc (formatting may differ slightly). - */ - import Asciidoctor from 'asciidoctor' +import { generateJSON } from '@tiptap/html' import type { JSONContent } from '@tiptap/core' +import StarterKit from '@tiptap/starter-kit' +import Link from '@tiptap/extension-link' +import { Admonition, CustomCodeBlock } from './asciidoc-extensions' const asciidoctor = Asciidoctor() // ── AsciiDoc → TipTap ──────────────────────────────────────────────────────── export function toTipTap(adoc: string): JSONContent { - if (!adoc.trim()) { + if (!adoc || !adoc.trim()) { return { type: 'doc', content: [{ type: 'paragraph' }] } } - const doc = asciidoctor.load(adoc, { safe: 'safe' }) - const blocks = (doc.getBlocks?.() ?? []) as AsciidoctorBlock[] - const content = blocks.flatMap(convertBlock).filter(Boolean) as JSONContent[] + // Convert AsciiDoc to HTML, showtitle: false to skip huge document header wrappers + let htmlContent = asciidoctor.convert(adoc, { + attributes: { showtitle: false }, + standalone: false + }) as string - return { - type: 'doc', - content: content.length ? content : [{ type: 'paragraph' }], - } + // Rewrite admonition tables to our custom tag before letting TipTap parse it + htmlContent = htmlContent.replace( + /
[\s\S]*?\s*([\s\S]*?)\s*<\/td>[\s\S]*?<\/div>/g, + (_match, type, content) => { + // The content might contain paragraphs from asciidoctor. + return `
${content}
` + } + ) + + const extensions = [ + StarterKit.configure({ codeBlock: false }), + CustomCodeBlock, + Link, + Admonition + ] + + return generateJSON(htmlContent, extensions) } // ── TipTap → AsciiDoc ──────────────────────────────────────────────────────── -export function fromTipTap(json: JSONContent): string { - if (!json.content?.length) return '' - return json.content.map(nodeToAdoc).join('\n\n') -} +class TipTapToAsciidoc { + json: JSONContent -// ── Internal converters ────────────────────────────────────────────────────── - -// Asciidoctor.js types are not fully typed — use a minimal interface. -interface AsciidoctorBlock { - getNodeName(): string - getLevel?(): number - getTitle?(): string - getSource?(): string - getSourceLanguage?(): string - getContent?(): string - getBlocks?(): AsciidoctorBlock[] - getItems?(): AsciidoctorBlock[] -} - -function convertBlock(block: AsciidoctorBlock): JSONContent | JSONContent[] { - const name = block.getNodeName() - - switch (name) { - case 'section': - case 'preamble': - return (block.getBlocks?.() ?? []).flatMap(convertBlock) - - case 'paragraph': - return { - type: 'paragraph', - content: parseInline(block.getContent?.() ?? ''), - } - - case 'listing': - case 'literal': { - const lang = block.getSourceLanguage?.() ?? '' - return { - type: 'codeBlock', - attrs: { language: lang || null }, - content: [{ type: 'text', text: block.getSource?.() ?? '' }], - } - } - - case 'ulist': - return { - type: 'bulletList', - content: (block.getItems?.() ?? []).map((item) => ({ - type: 'listItem', - content: [{ type: 'paragraph', content: parseInline(item.getContent?.() ?? '') }], - })), - } - - case 'olist': - return { - type: 'orderedList', - content: (block.getItems?.() ?? []).map((item) => ({ - type: 'listItem', - content: [{ type: 'paragraph', content: parseInline(item.getContent?.() ?? '') }], - })), - } - - default: - // Fallback: render as paragraph. - return { - type: 'paragraph', - content: parseInline(block.getContent?.() ?? block.getSource?.() ?? ''), - } + constructor(tiptapJson: JSONContent) { + this.json = tiptapJson } -} -/** Very basic inline markup → TipTap marks. */ -function parseInline(text: string): JSONContent[] { - // Strip Asciidoctor HTML output to plain text for now. - // A full implementation would parse *bold*, _italic_, `code` etc. - const plain = text.replace(/<[^>]+>/g, '') - return plain ? [{ type: 'text', text: plain }] : [] -} - -function nodeToAdoc(node: JSONContent): string { - switch (node.type) { - case 'paragraph': - return inlineToAdoc(node.content ?? []) - - case 'heading': { - const level = (node.attrs?.level as number) ?? 1 - const prefix = '='.repeat(level + 1) - return `${prefix} ${inlineToAdoc(node.content ?? [])}` + convert(): string { + if (!this.json || this.json.type !== 'doc' || !this.json.content) { + return '' } - - case 'codeBlock': { - const lang = (node.attrs?.language as string | null) ?? '' - const src = node.content?.[0]?.text ?? '' - return `[source${lang ? ',' + lang : ''}]\n----\n${src}\n----` - } - - case 'bulletList': - return (node.content ?? []) - .map((li) => `* ${inlineToAdoc(li.content?.[0]?.content ?? [])}`) - .join('\n') - - case 'orderedList': - return (node.content ?? []) - .map((li) => `. ${inlineToAdoc(li.content?.[0]?.content ?? [])}`) - .join('\n') - - case 'blockquote': - return `[quote]\n____\n${(node.content ?? []).map(nodeToAdoc).join('\n')}\n____` - - case 'horizontalRule': - return "'''" - - default: - return inlineToAdoc(node.content ?? []) + return this.processNodes(this.json.content).trim() } -} -function inlineToAdoc(nodes: JSONContent[]): string { - return nodes - .map((n) => { - const text = n.text ?? '' - const marks = (n.marks ?? []).map((m) => m.type) - let result = text - if (marks.includes('bold')) result = `*${result}*` - if (marks.includes('italic')) result = `_${result}_` - if (marks.includes('code')) result = `\`${result}\`` - return result + processNodes(nodes: JSONContent[], listLevel: number = 1, listType: 'bullet' | 'ordered' = 'bullet'): string { + let output = '' + + nodes.forEach((node) => { + switch (node.type) { + case 'heading': + output += this.convertHeading(node) + break + case 'paragraph': + output += this.convertParagraph(node) + break + case 'bulletList': + output += this.processNodes(node.content || [], listLevel, 'bullet') + output += '\n' // Extra linebreak after list + break + case 'orderedList': + output += this.processNodes(node.content || [], listLevel, 'ordered') + output += '\n' + break + case 'listItem': + output += this.convertListItem(node, listLevel, listType) + break + case 'codeBlock': + output += this.convertCodeBlock(node) + break + case 'blockquote': + output += this.convertBlockquote(node) + break + case 'admonition': + output += this.convertAdmonition(node) + break + case 'horizontalRule': + output += "'''\n\n" + break + default: + console.warn(`Ohanterad nod-typ: ${node.type}`) + } }) - .join('') + + return output + } + + convertHeading(node: JSONContent): string { + const level = (node.attrs?.level as number) || 1 + const prefix = '='.repeat(level) + const text = this.renderTextNodes(node.content || []) + return `${prefix} ${text}\n\n` + } + + convertParagraph(node: JSONContent): string { + if (!node.content || node.content.length === 0) return '{blank}\n\n' + const text = this.renderTextNodes(node.content) + return `${text}\n\n` + } + + convertListItem(node: JSONContent, level: number, type: 'bullet' | 'ordered'): string { + const prefix = type === 'ordered' ? '.'.repeat(level) : '*'.repeat(level) + + let itemText = node.content + ? this.processNodes(node.content, level + 1, type).trim() + : '' + + return `${prefix} ${itemText}\n` + } + + convertCodeBlock(node: JSONContent): string { + const lang = (node.attrs?.language as string) || '' + const code = this.renderTextNodes(node.content || []) + return `[source${lang ? ',' + lang : ''}]\n----\n${code}\n----\n\n` + } + + convertBlockquote(node: JSONContent): string { + const text = node.content ? this.processNodes(node.content).trim() : '' + return `____\n${text}\n____\n\n` + } + + convertAdmonition(node: JSONContent): string { + const type = (node.attrs?.type as string) || 'TIP' + const text = node.content ? this.processNodes(node.content).trim() : '' + return `[${type.toUpperCase()}]\n====\n${text}\n====\n\n` + } + + renderTextNodes(nodes: JSONContent[]): string { + if (!nodes || nodes.length === 0) return '' + let textOut = '' + + nodes.forEach((t) => { + if (t.type === 'text') { + let text = t.text || '' + if (t.marks) { + t.marks.forEach(mark => { + switch (mark.type) { + case 'bold': text = `*${text}*`; break + case 'italic': text = `_${text}_`; break + case 'strike': text = `[line-through]#${text}#`; break + case 'code': text = `\`${text}\``; break + case 'link': + const url = mark.attrs?.href || '' + if (url.startsWith('http://') || url.startsWith('https://')) { + text = `${url}[${text}]` + } else { + text = `link:${url}[${text}]` + } + break + } + }) + } + textOut += text + } + if (t.type === 'hardBreak') { + textOut += ' +\n' + } + }) + + return textOut + } +} + +export function fromTipTap(json: JSONContent): string { + const converter = new TipTapToAsciidoc(json) + return converter.convert() } diff --git a/frontend/src/bridge/asciidoc-extensions.ts b/frontend/src/bridge/asciidoc-extensions.ts new file mode 100644 index 0000000..5473187 --- /dev/null +++ b/frontend/src/bridge/asciidoc-extensions.ts @@ -0,0 +1,41 @@ +import { Node } from '@tiptap/core' +import CodeBlock from '@tiptap/extension-code-block' + +export const Admonition = Node.create({ + name: 'admonition', + group: 'block', + content: 'block+', + addAttributes() { + return { + type: { default: 'note' }, + } + }, + parseHTML() { + return [ + { + tag: 'div[data-type="admonition"]', + getAttrs: (el) => { + if (typeof el === 'string') return {} + return { type: el.getAttribute('data-admonition-type') || 'note' } + }, + }, + ] + }, + renderHTML({ HTMLAttributes }) { + return ['div', { 'data-type': 'admonition', 'data-admonition-type': HTMLAttributes.type, class: `admonition ${HTMLAttributes.type}` }, 0] + }, +}) + +export const CustomCodeBlock = CodeBlock.extend({ + addAttributes() { + return { + language: { + default: null, + parseHTML: (element) => { + const code = element.querySelector('code') || element + return code?.getAttribute('data-lang') || code?.className.match(/language-(\w+)/)?.[1] || null + }, + }, + } + }, +}) diff --git a/frontend/src/components/editor/VisualEditor.vue b/frontend/src/components/editor/VisualEditor.vue index a70c6b0..c20f82c 100644 --- a/frontend/src/components/editor/VisualEditor.vue +++ b/frontend/src/components/editor/VisualEditor.vue @@ -1,19 +1,34 @@ + + diff --git a/frontend/src/components/history/DiffViewer.vue b/frontend/src/components/history/DiffViewer.vue new file mode 100644 index 0000000..09d1db9 --- /dev/null +++ b/frontend/src/components/history/DiffViewer.vue @@ -0,0 +1,354 @@ + + + diff --git a/frontend/src/components/history/HistoryPanel.vue b/frontend/src/components/history/HistoryPanel.vue new file mode 100644 index 0000000..d0d4a51 --- /dev/null +++ b/frontend/src/components/history/HistoryPanel.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/components/layout/AppLayout.vue b/frontend/src/components/layout/AppLayout.vue index 6251689..789a614 100644 --- a/frontend/src/components/layout/AppLayout.vue +++ b/frontend/src/components/layout/AppLayout.vue @@ -39,7 +39,7 @@ const sidebarOpen = ref(false)
-
+