Initial release v1.0.0 — Nextcloud Collabora Node-RED sidecar
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# Nextcloud Collabora Node-RED sidecar — miljövariabler
|
||||||
|
# Kopiera till .env och fyll i app-lösenord i Node-RED UI (nc-collab-config)
|
||||||
|
|
||||||
|
NEXTCLOUD_URL=https://cloud.hannesahlin.se
|
||||||
|
COLLABORA_URL=http://host.docker.internal:9980
|
||||||
|
TZ=Europe/Stockholm
|
||||||
|
|
||||||
|
# App-lösenord konfigureras i Node-RED (sparas krypterat i /data),
|
||||||
|
# inte här — undvik hemligheter i miljöfiler.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
entrypoint.sh text eol=lf
|
||||||
|
install.sh text eol=lf
|
||||||
|
package.sh text eol=lf
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
/data/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.tar.gz
|
||||||
|
!artifacts/.gitkeep
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
FROM nodered/node-red:latest
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
COPY package.json /opt/nextcloud-collabora-nodered/package.json
|
||||||
|
COPY lib /opt/nextcloud-collabora-nodered/lib
|
||||||
|
COPY nodes /opt/nextcloud-collabora-nodered/nodes
|
||||||
|
COPY flows /opt/nextcloud-collabora-nodered/flows
|
||||||
|
COPY settings.js.example /opt/nextcloud-collabora-nodered/settings.js.example
|
||||||
|
COPY entrypoint.sh /opt/nextcloud-collabora-nodered/entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod +x /opt/nextcloud-collabora-nodered/entrypoint.sh && \
|
||||||
|
cd /opt/nextcloud-collabora-nodered && npm install --omit=dev
|
||||||
|
|
||||||
|
USER node-red
|
||||||
|
|
||||||
|
ENTRYPOINT ["/opt/nextcloud-collabora-nodered/entrypoint.sh"]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# nextcloud-collabora-nodered
|
||||||
|
|
||||||
|
Node-RED sidecar (port **1881**) för Nextcloud Office + Collabora CODE — WebDAV, richdocuments OCS och `/cool` REST.
|
||||||
|
|
||||||
|
**Version 1.0 — 22 augusti 2026**
|
||||||
|
|
||||||
|
## Snabbstart (Ubuntu)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <din-remote> /home/ncadmin/nextcloud-collabora-nodered
|
||||||
|
cd /home/ncadmin/nextcloud-collabora-nodered
|
||||||
|
cp .env.example .env
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Öppna http://192.168.1.26:1881 → konfigurera `nc-collab-config` med **app-lösenord** → kör acceptansflödet.
|
||||||
|
|
||||||
|
## Överföring från Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# På Windows (Git Bash/WSL):
|
||||||
|
./package.sh
|
||||||
|
|
||||||
|
# Kopiera till server:
|
||||||
|
scp artifacts/nextcloud-collabora-nodered-1.0.0.tar.gz ncadmin@192.168.1.26:/home/ncadmin/
|
||||||
|
|
||||||
|
# På Ubuntu:
|
||||||
|
mkdir -p ~/nextcloud-collabora-nodered
|
||||||
|
tar -xzf ~/nextcloud-collabora-nodered-1.0.0.tar.gz -C ~/nextcloud-collabora-nodered
|
||||||
|
cd ~/nextcloud-collabora-nodered && ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Noder
|
||||||
|
|
||||||
|
| Nod | Syfte |
|
||||||
|
|-----|-------|
|
||||||
|
| `nc-collab-config` | Miljö + app-lösenord |
|
||||||
|
| `collabora-bridge` | Agent-pipelines (börja här) |
|
||||||
|
| `nc-office` | richdocuments OCS |
|
||||||
|
| `collabora-convert` | `/cool/convert-to` (låg nivå) |
|
||||||
|
| `collabora-extract` | `/cool/extract-document-structure` |
|
||||||
|
| `collabora-transform` | `/cool/transform-document-structure` |
|
||||||
|
| `collabora-wopi` | discovery, capabilities, editor-URL |
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
- [docs/AGENT-MANUAL.md](docs/AGENT-MANUAL.md) — full assistentmanual för flödesagenter
|
||||||
|
- `artifacts/Assistentmanual-Nextcloud-Collabora-noder.docx` — genereras med `./scripts/build-manual.sh` (kräver pandoc)
|
||||||
|
|
||||||
|
## Gränser
|
||||||
|
|
||||||
|
- Skriver till `/Dokument/OfficeOut/` som standard (original orört)
|
||||||
|
- Port 9980 exponeras **inte** — sidecar når CODE via `host.docker.internal`
|
||||||
|
- Befintlig `nextcloud-node-red:1880` lämnas orörd
|
||||||
|
- Odds-scraper ignoreras
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git init # redan gjort vid leverans
|
||||||
|
git remote add origin <din-git-url>
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licens
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Docx-manual
|
||||||
|
|
||||||
|
Kör `./scripts/build-manual.sh` (kräver pandoc) för att generera:
|
||||||
|
|
||||||
|
`Assistentmanual-Nextcloud-Collabora-noder.docx`
|
||||||
|
|
||||||
|
Källtext: [../docs/AGENT-MANUAL.md](../docs/AGENT-MANUAL.md)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
nextcloud-collabora-nodered:
|
||||||
|
build: .
|
||||||
|
image: nextcloud-collabora-nodered:latest
|
||||||
|
container_name: nextcloud-collabora-nodered
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1881:1881"
|
||||||
|
environment:
|
||||||
|
PORT: "1881"
|
||||||
|
TZ: ${TZ:-Europe/Stockholm}
|
||||||
|
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.hannesahlin.se}
|
||||||
|
COLLABORA_URL: ${COLLABORA_URL:-http://host.docker.internal:9980}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
- "cloud.hannesahlin.se:192.168.1.26"
|
||||||
|
volumes:
|
||||||
|
- nodered_collab_data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nodered_collab_data:
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Assistentmanual — Nextcloud Office + Collabora Node-RED
|
||||||
|
|
||||||
|
**Version 1.0 — 22 augusti 2026**
|
||||||
|
|
||||||
|
Gäller sidecaren `nextcloud-collabora-nodered` mot Nextcloud på `cloud.hannesahlin.se` och Collabora CODE 26.04.x (container `code`, `127.0.0.1:9980`). Odds-scraper ignoreras.
|
||||||
|
|
||||||
|
Du är en **flödesagent**. Du skapar och modifierar Node-RED-flöden, inte LibreOffice-makron i webbläsaren. All fil-I/O går via Nextcloud WebDAV eller OCS. All dokumentmotor går via CODE REST.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Uppdrag och gränser
|
||||||
|
|
||||||
|
1. Använd **app-lösenord** i `nc-collab-config`. Aldrig vanligt kontolösen.
|
||||||
|
2. Skriv **inte över original**. Standardmål: `/Dokument/OfficeOut/`.
|
||||||
|
3. `postMessage`-API och `CallPythonScript` är iframe/webbläsare. **Inte Node-RED.**
|
||||||
|
4. WOPI PutFile är CODE:s jobb mot Nextcloud. Agenten använder **WebDAV PUT**.
|
||||||
|
5. Exponera inte port 9980 utanför loopback.
|
||||||
|
6. Rör inte odds-scraper eller Gitea-databaslösen i flöden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Miljö
|
||||||
|
|
||||||
|
| Nyckel | Värde |
|
||||||
|
|--------|-------|
|
||||||
|
| Nextcloud | `https://cloud.hannesahlin.se` (host 192.168.1.26) |
|
||||||
|
| CODE | container `code`, `127.0.0.1:9980`, aliasgroup `https://cloud.hannesahlin.se:443` |
|
||||||
|
| Befintlig Node-RED | `nextcloud-node-red` `:1880` (OCS-noder) |
|
||||||
|
| Ny sidecar | `nextcloud-collabora-nodered` `:1881` |
|
||||||
|
| Intern CODE-URL från sidecar | `http://host.docker.internal:9980` |
|
||||||
|
| Auth | Basic + Nextcloud app-lösenord, header `OCS-APIRequest: true` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Kartan Nextcloud → Collabora
|
||||||
|
|
||||||
|
| Nextcloud | Collabora-yta | Nod |
|
||||||
|
|-----------|-----------------|-----|
|
||||||
|
| WebDAV GET fil | input till convert/extract/transform | convert, extract, transform, bridge |
|
||||||
|
| WebDAV PUT | spara PDF/DOCX/PNG | samma + `destNcPath` |
|
||||||
|
| files convert OCS | samma motor via richdocuments | nc-office `files:convert` |
|
||||||
|
| richdocuments mallar | convert-to `template=` eller NC create-from-template | nc-office `templates:*` |
|
||||||
|
| template fields | content controls | extractFields / fillFields / transform |
|
||||||
|
| file versions | compare= på convert-to | bridge `compareVersionsPdf` |
|
||||||
|
| fileId + direct token | WOPISrc + editor iframe | nc-office `document:direct`, wopi `buildEditor` |
|
||||||
|
| assets API | linking / insert graphic URL | assets:create + klient postMessage |
|
||||||
|
| shares | public edit URL | document:public |
|
||||||
|
| preview | convert-to png | ncFileToPng |
|
||||||
|
| HTML/MD | convert-to office | htmlToOffice |
|
||||||
|
|
||||||
|
Talk, Tables, Mail, Collectives ska **konsumera resultatet** (dela PDF, bifoga, lägg i collective) — de ersätter inte WOPI-host.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Noder och msg-kontrakt
|
||||||
|
|
||||||
|
### 4.1 nc-collab-config
|
||||||
|
|
||||||
|
En instans per miljö. Fält: `nextcloudUrl`, `collaboraUrl`, ev. `collaboraPublicUrl`, `username`, app-lösen, `verifyTls` (false i labb-VM tills certkedjan är ren).
|
||||||
|
|
||||||
|
### 4.2 Gemensamma msg-fält
|
||||||
|
|
||||||
|
| Fält | Betydelse |
|
||||||
|
|------|-----------|
|
||||||
|
| `ncPath` | Sökväg i användarens filrot, t.ex. `/Mallar/avtal.docx` |
|
||||||
|
| `fileId` | Numeriskt Nextcloud-fileid |
|
||||||
|
| `payload` | Buffer eller text om källan inte ligger i NC |
|
||||||
|
| `format` | pdf \| png \| docx \| odt \| html \| txt \| xlsx \| pptx … |
|
||||||
|
| `destNcPath` / `destFolder` / `destName` | Var resultatet skrivs |
|
||||||
|
| `transform` / `fields` | JSON för struktur-API eller mallfält |
|
||||||
|
| `templateNcPath` / `compareNcPath` | Extra filer till convert-to |
|
||||||
|
| `operation` | Överstyr dropdown |
|
||||||
|
| `statusCode` / `error` / `filename` / `contentType` | Utdata |
|
||||||
|
|
||||||
|
### 4.3 Operationskatalog
|
||||||
|
|
||||||
|
| Nod.operation | När |
|
||||||
|
|---------------|-----|
|
||||||
|
| bridge.ncFileToPdf/Png/Html | Standardkonvertering NC-fil → NC-fil |
|
||||||
|
| bridge.htmlToOffice | Agentgenererad HTML/MD → office |
|
||||||
|
| bridge.fillTemplateSave | Fyll content controls/fält och spara kopia |
|
||||||
|
| bridge.compareVersionsPdf | Ny + gammal fil → PDF med ändringar |
|
||||||
|
| bridge.extractToPayload | Struktur-JSON till nästa nod |
|
||||||
|
| bridge.createAssetLink | Engångs-URL för insert i öppet dokument |
|
||||||
|
| nc-office.templates:list\|create | Office-mallar i richdocuments |
|
||||||
|
| nc-office.template:extractFields\|fillFields | Föredra när fileId finns |
|
||||||
|
| nc-office.document:direct\|public | Engångs redigerings-URL |
|
||||||
|
| nc-office.files:convert | Konvertera via Nextcloud (CODE bakom) |
|
||||||
|
| convert + extract + transform | Låg nivå när bridge inte räcker |
|
||||||
|
| wopi.discovery\|capabilities | Hälsokoll och mime→urlsrc |
|
||||||
|
| wopi.buildEditor | Bygg editorUrl om token redan finns |
|
||||||
|
| wopi.checkFileInfo\|getFile | Endast felsökning av WOPI-host |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Recept
|
||||||
|
|
||||||
|
### A. NC-fil till PDF
|
||||||
|
|
||||||
|
```
|
||||||
|
inject {ncPath} → collabora-bridge ncFileToPdf → ev. filesharing createShare (OCS :1880)
|
||||||
|
```
|
||||||
|
|
||||||
|
Kontrollera `destNcPath` i svaret.
|
||||||
|
|
||||||
|
### B. Avtalsmall
|
||||||
|
|
||||||
|
```
|
||||||
|
extractFields eller extract contentcontrol
|
||||||
|
→ mappa data till fields/transform
|
||||||
|
→ fillTemplateSave eller template:fillFields med destination
|
||||||
|
→ valfritt ncFileToPdf
|
||||||
|
→ document:direct för mänsklig granskning
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. Versionsdiff
|
||||||
|
|
||||||
|
```
|
||||||
|
{ncPath: ny, compareNcPath: gammal} → compareVersionsPdf
|
||||||
|
→ skicka PDF via Talk eller Mail-noden i OCS-paketet (:1880)
|
||||||
|
```
|
||||||
|
|
||||||
|
### D. Generera dokument
|
||||||
|
|
||||||
|
HTML i `payload` → `htmlToOffice` format docx eller pdf. Använd mall via convert `templateNcPath` om typografi måste följa husmall.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Vad du inte ska bygga
|
||||||
|
|
||||||
|
- En Node-RED-nod som postMessage:ar in i Collabora-iframe (kräver webbsida utanför iframen).
|
||||||
|
- Dynamisk Python mot share/Scripts/python (kräver paket på CODE och omstart).
|
||||||
|
- Andra CODE-container i compose (`code` körs redan med aliasgroup1).
|
||||||
|
- Portkrock på 1880 mot nextcloud-node-red.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Felsökning
|
||||||
|
|
||||||
|
| Symptom | Orsak | Åtgärd |
|
||||||
|
|---------|-------|--------|
|
||||||
|
| ECONNREFUSED :9980 | Fel Docker-nät | host.docker.internal, 172.17.0.2 eller network_mode host |
|
||||||
|
| Tomt convert-svar | CODE storage / fel URL | Anropa intern 9980, inte publik host |
|
||||||
|
| WebDAV 401 | App-lösen / user | Nytt app-lösen, samma uid som sökvägen |
|
||||||
|
| WebDAV 404 | Fel sökväg | Leading slash, rätt folder, URL-encode segment |
|
||||||
|
| extract tomt | Filter / capability | capabilities, prova documentproperties |
|
||||||
|
| WOPI 401/proof | Sessionstoken | Lämna WOPI. Använd WebDAV. |
|
||||||
|
| TLS-fel mot NC | Cert i containern | verifyTls=false |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Acceptanskriterier för ett flöde
|
||||||
|
|
||||||
|
- `capabilities` mot CODE returnerar JSON.
|
||||||
|
- En känd testdokumentsökväg konverteras till PDF under `/Dokument/OfficeOut/`.
|
||||||
|
- Originalfilen är oförändrad.
|
||||||
|
- Fel hamnar i `msg.error` och nodstatus röd, flödet kraschar inte tyst.
|
||||||
|
- Inga hemligheter i debug-payload till Talk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Installation (Ubuntu)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /home/ncadmin/nextcloud-collabora-nodered
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh
|
||||||
|
# UI: http://192.168.1.26:1881
|
||||||
|
```
|
||||||
|
|
||||||
|
Överföring från Windows:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./package.sh # skapar artifacts/nextcloud-collabora-nodered-1.0.0.tar.gz
|
||||||
|
scp artifacts/*.tar.gz ncadmin@192.168.1.26:/home/ncadmin/
|
||||||
|
# på servern:
|
||||||
|
tar -xzf nextcloud-collabora-nodered-1.0.0.tar.gz -C nextcloud-collabora-nodered
|
||||||
|
cd nextcloud-collabora-nodered && ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Arkitektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Befintligt (orört):
|
||||||
|
nextcloud-ocs 16 noder + nextcloud-config
|
||||||
|
container nextcloud-node-red :1880
|
||||||
|
CODE "code" :127.0.0.1:9980
|
||||||
|
|
||||||
|
Ny yta (det här paketet):
|
||||||
|
nc-collab-config
|
||||||
|
nc-office richdocuments OCS
|
||||||
|
collabora-convert /cool/convert-to
|
||||||
|
collabora-extract /cool/extract-document-structure
|
||||||
|
collabora-transform /cool/transform-document-structure
|
||||||
|
collabora-wopi discovery, capabilities, editor-URL
|
||||||
|
collabora-bridge agent-pipelines
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*cloud.hannesahlin.se · nextcloud-collabora-nodered v1.0*
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PKG_SRC="/opt/nextcloud-collabora-nodered"
|
||||||
|
PKG_DEST="/data/node_modules/node-red-contrib-nextcloud-collabora"
|
||||||
|
|
||||||
|
rm -rf "$PKG_DEST"
|
||||||
|
cp -a "$PKG_SRC" "$PKG_DEST"
|
||||||
|
|
||||||
|
if [ ! -f /data/settings.js ] && [ -f "$PKG_SRC/settings.js.example" ]; then
|
||||||
|
cp "$PKG_SRC/settings.js.example" /data/settings.js
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$PKG_SRC/flows/example-flows.json" ] && [ ! -f /data/flows.json ]; then
|
||||||
|
cp "$PKG_SRC/flows/example-flows.json" /data/flows.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec npm --prefix /usr/src/node-red start -- --userDir /data --port "${PORT:-1881}"
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "collab_tab",
|
||||||
|
"type": "tab",
|
||||||
|
"label": "NC Collabora — acceptans",
|
||||||
|
"disabled": false,
|
||||||
|
"info": "Version 1.0 — Hälsokoll + testkonvertering till /Dokument/OfficeOut/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nc_collab_cfg",
|
||||||
|
"type": "nc-collab-config",
|
||||||
|
"name": "cloud.hannesahlin.se",
|
||||||
|
"nextcloudUrl": "https://cloud.hannesahlin.se",
|
||||||
|
"collaboraUrl": "http://host.docker.internal:9980",
|
||||||
|
"collaboraPublicUrl": "",
|
||||||
|
"username": "FILL_IN_USER",
|
||||||
|
"verifyTls": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inj_capabilities",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "collab_tab",
|
||||||
|
"name": "1. CODE capabilities",
|
||||||
|
"props": [],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"topic": "",
|
||||||
|
"x": 150,
|
||||||
|
"y": 80,
|
||||||
|
"wires": [["wopi_cap"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wopi_cap",
|
||||||
|
"type": "collabora-wopi",
|
||||||
|
"z": "collab_tab",
|
||||||
|
"name": "capabilities",
|
||||||
|
"config": "nc_collab_cfg",
|
||||||
|
"operation": "capabilities",
|
||||||
|
"x": 380,
|
||||||
|
"y": 80,
|
||||||
|
"wires": [["dbg_cap"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dbg_cap",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "collab_tab",
|
||||||
|
"name": "capabilities JSON",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "payload",
|
||||||
|
"x": 590,
|
||||||
|
"y": 80,
|
||||||
|
"wires": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "inj_pdf",
|
||||||
|
"type": "inject",
|
||||||
|
"z": "collab_tab",
|
||||||
|
"name": "2. NC-fil → PDF",
|
||||||
|
"props": [
|
||||||
|
{
|
||||||
|
"p": "ncPath",
|
||||||
|
"v": "/Dokument/test.docx",
|
||||||
|
"vt": "str"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"p": "destNcPath",
|
||||||
|
"v": "/Dokument/OfficeOut/test.pdf",
|
||||||
|
"vt": "str"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repeat": "",
|
||||||
|
"crontab": "",
|
||||||
|
"once": false,
|
||||||
|
"topic": "",
|
||||||
|
"x": 150,
|
||||||
|
"y": 160,
|
||||||
|
"wires": [["bridge_pdf"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bridge_pdf",
|
||||||
|
"type": "collabora-bridge",
|
||||||
|
"z": "collab_tab",
|
||||||
|
"name": "ncFileToPdf",
|
||||||
|
"config": "nc_collab_cfg",
|
||||||
|
"operation": "ncFileToPdf",
|
||||||
|
"x": 380,
|
||||||
|
"y": 160,
|
||||||
|
"wires": [["dbg_pdf"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dbg_pdf",
|
||||||
|
"type": "debug",
|
||||||
|
"z": "collab_tab",
|
||||||
|
"name": "destNcPath (inga hemligheter)",
|
||||||
|
"active": true,
|
||||||
|
"tosidebar": true,
|
||||||
|
"console": false,
|
||||||
|
"tostatus": false,
|
||||||
|
"complete": "destNcPath",
|
||||||
|
"x": 620,
|
||||||
|
"y": 160,
|
||||||
|
"wires": []
|
||||||
|
}
|
||||||
|
]
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install.sh — nextcloud-collabora-nodered v1.0
|
||||||
|
# Kör på Ubuntu/Nextcloud-servern efter överföring från Windows.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="1.0.0"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-$SCRIPT_DIR}"
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
|
||||||
|
log() { printf '[install] %s\n' "$*"; }
|
||||||
|
die() { printf '[install] FEL: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || die "Saknar kommando: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "nextcloud-collabora-nodered v${VERSION}"
|
||||||
|
log "Installationskatalog: ${INSTALL_DIR}"
|
||||||
|
|
||||||
|
require_cmd docker
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE="docker-compose"
|
||||||
|
else
|
||||||
|
die "Docker Compose saknas (docker compose eller docker-compose)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
if [ ! -f docker-compose.yml ]; then
|
||||||
|
die "docker-compose.yml saknas i ${INSTALL_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
log "Skapar .env från .env.example"
|
||||||
|
cp .env.example .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Valfri tarball-import (skickad från Windows)
|
||||||
|
if [ -n "${IMPORT_TARBALL:-}" ] && [ -f "${IMPORT_TARBALL}" ]; then
|
||||||
|
log "Extraherar ${IMPORT_TARBALL}"
|
||||||
|
tmpdir="$(mktemp -d)"
|
||||||
|
tar -xzf "${IMPORT_TARBALL}" -C "${tmpdir}"
|
||||||
|
src="$(find "${tmpdir}" -maxdepth 2 -name docker-compose.yml -printf '%h\n' | head -1)"
|
||||||
|
[ -n "${src}" ] || die "Ogiltig tarball — saknar docker-compose.yml"
|
||||||
|
rsync -a "${src}/" "${INSTALL_DIR}/"
|
||||||
|
rm -rf "${tmpdir}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Bygger och startar container (port 1881)..."
|
||||||
|
${COMPOSE} build --no-cache
|
||||||
|
${COMPOSE} up -d
|
||||||
|
|
||||||
|
log "Väntar på Node-RED..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf "http://127.0.0.1:1881" >/dev/null 2>&1; then
|
||||||
|
log "Node-RED svarar på http://127.0.0.1:1881"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
if [ "$i" -eq 30 ]; then
|
||||||
|
log "Varning: Node-RED svarade inte inom 60 s — kontrollera: ${COMPOSE} logs -f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
Nästa steg:
|
||||||
|
1. Öppna http://<server-ip>:1881
|
||||||
|
2. Lägg till nc-collab-config med app-lösenord (inte kontolösen)
|
||||||
|
3. Importera/kör exempelflödet (flows/example-flows.json)
|
||||||
|
4. Testa: capabilities → ncFileToPdf mot känd fil → /Dokument/OfficeOut/
|
||||||
|
|
||||||
|
CODE ska redan köra på 127.0.0.1:9980 — starta inte ny CODE-container.
|
||||||
|
Befintlig nextcloud-node-red på :1880 lämnas orörd.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log "Klart."
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { webdavGet, webdavPut, webdavPropfindFileId } = require('./webdav');
|
||||||
|
const { ocsRequest } = require('./ocs');
|
||||||
|
const { convertTo, extractStructure, transformStructure } = require('./collabora');
|
||||||
|
const {
|
||||||
|
resolveDestNcPath,
|
||||||
|
assertSafeDestination,
|
||||||
|
contentTypeForFormat,
|
||||||
|
replaceExtension,
|
||||||
|
basenameFromNcPath,
|
||||||
|
normalizeNcPath,
|
||||||
|
pickMsgField
|
||||||
|
} = require('./utils');
|
||||||
|
|
||||||
|
async function resolveFileId(config, msg) {
|
||||||
|
if (msg.fileId) {
|
||||||
|
return parseInt(msg.fileId, 10);
|
||||||
|
}
|
||||||
|
const ncPath = pickMsgField(msg, 'ncPath');
|
||||||
|
if (ncPath) {
|
||||||
|
return webdavPropfindFileId(config, ncPath);
|
||||||
|
}
|
||||||
|
throw new Error('Ange msg.fileId eller msg.ncPath');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSourceBuffer(config, msg) {
|
||||||
|
if (msg.payload !== undefined && msg.payload !== null && msg.payload !== '') {
|
||||||
|
if (Buffer.isBuffer(msg.payload)) {
|
||||||
|
return {
|
||||||
|
buffer: msg.payload,
|
||||||
|
filename: msg.filename || 'payload.bin',
|
||||||
|
ncPath: msg.ncPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const text = typeof msg.payload === 'string' ? msg.payload : JSON.stringify(msg.payload);
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(text, 'utf8'),
|
||||||
|
filename: msg.filename || 'payload.txt',
|
||||||
|
ncPath: msg.ncPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ncPath = pickMsgField(msg, 'ncPath');
|
||||||
|
if (!ncPath) {
|
||||||
|
throw new Error('Ange msg.ncPath eller msg.payload');
|
||||||
|
}
|
||||||
|
const file = await webdavGet(config, ncPath);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResult(config, msg, buffer, format, sourceNcPath) {
|
||||||
|
const destNcPath = assertSafeDestination(
|
||||||
|
resolveDestNcPath(msg, sourceNcPath, format),
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
const contentType = contentTypeForFormat(format);
|
||||||
|
const putResult = await webdavPut(config, destNcPath, buffer, contentType);
|
||||||
|
return {
|
||||||
|
destNcPath: putResult.destNcPath,
|
||||||
|
filename: putResult.filename,
|
||||||
|
contentType,
|
||||||
|
statusCode: putResult.statusCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ncFileConvert(config, msg, format) {
|
||||||
|
const source = await loadSourceBuffer(config, msg);
|
||||||
|
const extraFields = [];
|
||||||
|
|
||||||
|
if (msg.templateNcPath) {
|
||||||
|
const tpl = await webdavGet(config, msg.templateNcPath);
|
||||||
|
extraFields.push({
|
||||||
|
name: 'template',
|
||||||
|
buffer: tpl.buffer,
|
||||||
|
filename: tpl.filename
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.compareNcPath) {
|
||||||
|
const cmp = await webdavGet(config, msg.compareNcPath);
|
||||||
|
extraFields.push({
|
||||||
|
name: 'compare',
|
||||||
|
buffer: cmp.buffer,
|
||||||
|
filename: cmp.filename
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const converted = await convertTo(config, {
|
||||||
|
buffer: source.buffer,
|
||||||
|
filename: source.filename,
|
||||||
|
format,
|
||||||
|
extraFields
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await saveResult(config, msg, converted.buffer, format, source.ncPath);
|
||||||
|
return {
|
||||||
|
...saved,
|
||||||
|
format,
|
||||||
|
sourceNcPath: source.ncPath,
|
||||||
|
payload: converted.buffer
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ncFileToPdf(config, msg) {
|
||||||
|
return ncFileConvert(config, msg, 'pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ncFileToPng(config, msg) {
|
||||||
|
return ncFileConvert(config, msg, 'png');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ncFileToHtml(config, msg) {
|
||||||
|
return ncFileConvert(config, msg, 'html');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function htmlToOffice(config, msg) {
|
||||||
|
const format = (pickMsgField(msg, 'format') || 'docx').toLowerCase();
|
||||||
|
const source = await loadSourceBuffer(config, msg);
|
||||||
|
let buffer = source.buffer;
|
||||||
|
let filename = source.filename || 'content.html';
|
||||||
|
|
||||||
|
if (!filename.endsWith('.html') && !filename.endsWith('.htm')) {
|
||||||
|
filename = 'content.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraFields = [];
|
||||||
|
if (msg.templateNcPath) {
|
||||||
|
const tpl = await webdavGet(config, msg.templateNcPath);
|
||||||
|
extraFields.push({
|
||||||
|
name: 'template',
|
||||||
|
buffer: tpl.buffer,
|
||||||
|
filename: tpl.filename
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const converted = await convertTo(config, {
|
||||||
|
buffer,
|
||||||
|
filename,
|
||||||
|
format,
|
||||||
|
extraFields
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await saveResult(config, msg, converted.buffer, format, msg.ncPath || filename);
|
||||||
|
return {
|
||||||
|
...saved,
|
||||||
|
format,
|
||||||
|
payload: converted.buffer
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillTemplateSave(config, msg) {
|
||||||
|
const fileId = await resolveFileId(config, msg);
|
||||||
|
const fields = msg.fields || msg.transform;
|
||||||
|
if (!fields) {
|
||||||
|
throw new Error('Ange msg.fields eller msg.transform för mallifyllning');
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = msg.destNcPath
|
||||||
|
? assertSafeDestination(normalizeNcPath(msg.destNcPath), msg)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const body = { fields };
|
||||||
|
if (destination) {
|
||||||
|
body.destination = destination.startsWith('/') ? destination.slice(1) : destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ocsRequest(config, {
|
||||||
|
method: 'POST',
|
||||||
|
path: `/ocs/v2.php/apps/richdocuments/api/v1/template/fields/fill/${fileId}`,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
const destNcPath = destination || resolveDestNcPath(msg, msg.ncPath, 'docx');
|
||||||
|
return {
|
||||||
|
fileId,
|
||||||
|
destNcPath: normalizeNcPath('/' + (body.destination || destNcPath.replace(/^\//, ''))),
|
||||||
|
statusCode: 200
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compareVersionsPdf(config, msg) {
|
||||||
|
if (!msg.compareNcPath) {
|
||||||
|
throw new Error('compareVersionsPdf kräver msg.compareNcPath (äldre version)');
|
||||||
|
}
|
||||||
|
msg.format = 'pdf';
|
||||||
|
return ncFileToPdf(config, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractToPayload(config, msg) {
|
||||||
|
if (msg.fileId) {
|
||||||
|
const res = await ocsRequest(config, {
|
||||||
|
method: 'GET',
|
||||||
|
path: `/ocs/v2.php/apps/richdocuments/api/v1/template/fields/extract/${msg.fileId}`
|
||||||
|
});
|
||||||
|
return { fields: res.data, fileId: msg.fileId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await loadSourceBuffer(config, msg);
|
||||||
|
const filter = msg.filter || 'contentcontrol';
|
||||||
|
const structure = await extractStructure(config, {
|
||||||
|
buffer: source.buffer,
|
||||||
|
filename: source.filename,
|
||||||
|
filter
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
structure,
|
||||||
|
sourceNcPath: source.ncPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAssetLink(config, msg) {
|
||||||
|
const assetPath = pickMsgField(msg, 'ncPath');
|
||||||
|
if (!assetPath) {
|
||||||
|
throw new Error('createAssetLink kräver msg.ncPath');
|
||||||
|
}
|
||||||
|
|
||||||
|
const relPath = normalizeNcPath(assetPath);
|
||||||
|
const { requestBuffer } = require('./http-client');
|
||||||
|
const base = config.nextcloudUrl.replace(/\/$/, '');
|
||||||
|
const token = Buffer.from(`${config.username}:${config.password}`).toString('base64');
|
||||||
|
const res = await requestBuffer({
|
||||||
|
url: `${base}/index.php/apps/richdocuments/assets`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${token}`,
|
||||||
|
'OCS-APIRequest': 'true',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
body: Buffer.from(JSON.stringify({ path: relPath }), 'utf8'),
|
||||||
|
rejectUnauthorized: config.verifyTls !== false && config.verifyTls !== 'false'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw new Error(`assets:create misslyckades (${res.statusCode})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = JSON.parse(res.body.toString('utf8'));
|
||||||
|
return {
|
||||||
|
assetUrl: json.url || json.assetUrl || json,
|
||||||
|
ncPath: relPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ncFileToPdf,
|
||||||
|
ncFileToPng,
|
||||||
|
ncFileToHtml,
|
||||||
|
htmlToOffice,
|
||||||
|
fillTemplateSave,
|
||||||
|
compareVersionsPdf,
|
||||||
|
extractToPayload,
|
||||||
|
createAssetLink,
|
||||||
|
loadSourceBuffer,
|
||||||
|
saveResult,
|
||||||
|
resolveFileId
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const FormData = require('form-data');
|
||||||
|
const { requestStream } = require('./http-client');
|
||||||
|
|
||||||
|
function collaboraBase(config) {
|
||||||
|
return (config.collaboraUrl || '').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tlsOptions(config) {
|
||||||
|
return config.verifyTls !== false && config.verifyTls !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMultipart(fields) {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.buffer) {
|
||||||
|
form.append(field.name, field.buffer, {
|
||||||
|
filename: field.filename || 'document.bin',
|
||||||
|
contentType: field.contentType || 'application/octet-stream'
|
||||||
|
});
|
||||||
|
} else if (field.value !== undefined) {
|
||||||
|
form.append(field.name, String(field.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postMultipart(config, endpoint, fields) {
|
||||||
|
const form = buildMultipart(fields);
|
||||||
|
const url = `${collaboraBase(config)}${endpoint}`;
|
||||||
|
|
||||||
|
const res = await requestStream(
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers: form.getHeaders(),
|
||||||
|
rejectUnauthorized: tlsOptions(config)
|
||||||
|
},
|
||||||
|
(req) => form.pipe(req)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
const snippet = res.body.toString('utf8').slice(0, 300);
|
||||||
|
throw new Error(`Collabora ${endpoint} misslyckades (${res.statusCode}): ${snippet}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.body || res.body.length === 0) {
|
||||||
|
throw new Error(`Collabora ${endpoint} returnerade tomt svar — kontrollera intern URL och post_allow`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: res.body,
|
||||||
|
contentType: res.headers['content-type'],
|
||||||
|
statusCode: res.statusCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertTo(config, { buffer, filename, format, extraFields = [] }) {
|
||||||
|
const fmt = String(format || 'pdf').toLowerCase();
|
||||||
|
const fields = [
|
||||||
|
{ name: 'data', buffer, filename: filename || 'document.bin' },
|
||||||
|
...extraFields
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await postMultipart(config, `/cool/convert-to/${fmt}`, fields);
|
||||||
|
} catch (err) {
|
||||||
|
fields.push({ name: 'format', value: fmt });
|
||||||
|
return postMultipart(config, '/cool/convert-to', fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractStructure(config, { buffer, filename, filter }) {
|
||||||
|
const fields = [{ name: 'data', buffer, filename: filename || 'document.bin' }];
|
||||||
|
if (filter) {
|
||||||
|
fields.push({ name: 'filter', value: typeof filter === 'string' ? filter : JSON.stringify(filter) });
|
||||||
|
}
|
||||||
|
const res = await postMultipart(config, '/cool/extract-document-structure', fields);
|
||||||
|
try {
|
||||||
|
return JSON.parse(res.buffer.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
return { raw: res.buffer.toString('utf8') };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transformStructure(config, { buffer, filename, transform }) {
|
||||||
|
const fields = [
|
||||||
|
{ name: 'data', buffer, filename: filename || 'document.bin' },
|
||||||
|
{ name: 'transform', value: typeof transform === 'string' ? transform : JSON.stringify(transform) }
|
||||||
|
];
|
||||||
|
const res = await postMultipart(config, '/cool/transform-document-structure', fields);
|
||||||
|
return {
|
||||||
|
buffer: res.buffer,
|
||||||
|
contentType: res.contentType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCapabilities(config) {
|
||||||
|
const url = `${collaboraBase(config)}/hosting/capabilities`;
|
||||||
|
const { requestBuffer } = require('./http-client');
|
||||||
|
const res = await requestBuffer({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
rejectUnauthorized: tlsOptions(config)
|
||||||
|
});
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw new Error(`Capabilities misslyckades (${res.statusCode})`);
|
||||||
|
}
|
||||||
|
return JSON.parse(res.body.toString('utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDiscovery(config) {
|
||||||
|
const url = `${collaboraBase(config)}/hosting/discovery`;
|
||||||
|
const { requestBuffer } = require('./http-client');
|
||||||
|
const res = await requestBuffer({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
rejectUnauthorized: tlsOptions(config)
|
||||||
|
});
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw new Error(`Discovery misslyckades (${res.statusCode})`);
|
||||||
|
}
|
||||||
|
return res.body.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
convertTo,
|
||||||
|
extractStructure,
|
||||||
|
transformStructure,
|
||||||
|
getCapabilities,
|
||||||
|
getDiscovery,
|
||||||
|
postMultipart
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const { URL } = require('url');
|
||||||
|
|
||||||
|
function requestBuffer(options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(options.url);
|
||||||
|
const transport = url.protocol === 'https:' ? https : http;
|
||||||
|
const reqOptions = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: options.headers || {},
|
||||||
|
rejectUnauthorized: options.rejectUnauthorized !== false
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = transport.request(reqOptions, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
headers: res.headers,
|
||||||
|
body: Buffer.concat(chunks)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
if (options.body) {
|
||||||
|
req.write(options.body);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestStream(options, writeBody) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(options.url);
|
||||||
|
const transport = url.protocol === 'https:' ? https : http;
|
||||||
|
const reqOptions = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method: options.method || 'POST',
|
||||||
|
headers: options.headers || {},
|
||||||
|
rejectUnauthorized: options.rejectUnauthorized !== false
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = transport.request(reqOptions, (res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
headers: res.headers,
|
||||||
|
body: Buffer.concat(chunks)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
if (writeBody) {
|
||||||
|
writeBody(req);
|
||||||
|
} else if (options.body) {
|
||||||
|
req.write(options.body);
|
||||||
|
req.end();
|
||||||
|
} else {
|
||||||
|
req.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
requestBuffer,
|
||||||
|
requestStream
|
||||||
|
};
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { requestBuffer } = require('./http-client');
|
||||||
|
|
||||||
|
function authHeader(config) {
|
||||||
|
const token = Buffer.from(`${config.username}:${config.password}`).toString('base64');
|
||||||
|
return `Basic ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ocsUrl(config, path, query) {
|
||||||
|
const base = config.nextcloudUrl.replace(/\/$/, '');
|
||||||
|
const q = query || 'format=json';
|
||||||
|
const sep = path.includes('?') ? '&' : '?';
|
||||||
|
return `${base}${path}${sep}${q}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tlsOptions(config) {
|
||||||
|
return config.verifyTls !== false && config.verifyTls !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOcsBody(bodyBuffer) {
|
||||||
|
const text = bodyBuffer.toString('utf8');
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
const ocs = json.ocs || json;
|
||||||
|
const meta = ocs.meta || {};
|
||||||
|
const statuscode = meta.statuscode ?? meta.statusCode ?? 200;
|
||||||
|
if (statuscode >= 400) {
|
||||||
|
const message = meta.message || meta.status || text.slice(0, 200);
|
||||||
|
throw new Error(`OCS ${statuscode}: ${message}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode: statuscode,
|
||||||
|
data: ocs.data,
|
||||||
|
raw: json
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.startsWith('OCS ')) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new Error(`OCS-svar kunde inte parsas: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ocsRequest(config, { method = 'GET', path, query, body, headers = {}, contentType }) {
|
||||||
|
const url = ocsUrl(config, path, query);
|
||||||
|
const reqHeaders = {
|
||||||
|
Authorization: authHeader(config),
|
||||||
|
'OCS-APIRequest': 'true',
|
||||||
|
Accept: 'application/json',
|
||||||
|
...headers
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
if (body !== undefined) {
|
||||||
|
if (Buffer.isBuffer(body)) {
|
||||||
|
payload = body;
|
||||||
|
} else if (typeof body === 'string') {
|
||||||
|
payload = Buffer.from(body, 'utf8');
|
||||||
|
reqHeaders['Content-Type'] = contentType || 'application/json';
|
||||||
|
} else {
|
||||||
|
payload = Buffer.from(JSON.stringify(body), 'utf8');
|
||||||
|
reqHeaders['Content-Type'] = contentType || 'application/json';
|
||||||
|
}
|
||||||
|
reqHeaders['Content-Length'] = payload.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await requestBuffer({
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
headers: reqHeaders,
|
||||||
|
body: payload,
|
||||||
|
rejectUnauthorized: tlsOptions(config)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||||
|
throw new Error(`OCS ${res.statusCode}: kontrollera app-lösenord`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
httpStatus: res.statusCode,
|
||||||
|
...parseOcsBody(res.body)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ocsRequest,
|
||||||
|
ocsUrl
|
||||||
|
};
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DEFAULT_OUTPUT_FOLDER = '/Dokument/OfficeOut/';
|
||||||
|
|
||||||
|
const MIME_BY_EXT = {
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
png: 'image/png',
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
odt: 'application/vnd.oasis.opendocument.text',
|
||||||
|
html: 'text/html',
|
||||||
|
htm: 'text/html',
|
||||||
|
txt: 'text/plain',
|
||||||
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeNcPath(ncPath) {
|
||||||
|
if (!ncPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let p = String(ncPath).trim();
|
||||||
|
if (!p.startsWith('/')) {
|
||||||
|
p = '/' + p;
|
||||||
|
}
|
||||||
|
return p.replace(/\/+/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function basenameFromNcPath(ncPath) {
|
||||||
|
const p = normalizeNcPath(ncPath);
|
||||||
|
if (!p) {
|
||||||
|
return 'output.bin';
|
||||||
|
}
|
||||||
|
return path.posix.basename(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceExtension(filename, newExt) {
|
||||||
|
const base = filename.replace(/\.[^/.]+$/, '');
|
||||||
|
const ext = newExt.startsWith('.') ? newExt.slice(1) : newExt;
|
||||||
|
return `${base}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeForFormat(format) {
|
||||||
|
if (!format) {
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
return MIME_BY_EXT[String(format).toLowerCase()] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDestNcPath(sourceNcPath, format) {
|
||||||
|
const name = replaceExtension(basenameFromNcPath(sourceNcPath), format || 'bin');
|
||||||
|
return normalizeNcPath(DEFAULT_OUTPUT_FOLDER + name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDestNcPath(msg, sourceNcPath, format) {
|
||||||
|
if (msg.destNcPath) {
|
||||||
|
return normalizeNcPath(msg.destNcPath);
|
||||||
|
}
|
||||||
|
if (msg.destFolder || msg.destName) {
|
||||||
|
const folder = normalizeNcPath(msg.destFolder || DEFAULT_OUTPUT_FOLDER);
|
||||||
|
const folderWithSlash = folder.endsWith('/') ? folder : folder + '/';
|
||||||
|
const name = msg.destName || replaceExtension(basenameFromNcPath(sourceNcPath), format || 'bin');
|
||||||
|
return normalizeNcPath(folderWithSlash + name);
|
||||||
|
}
|
||||||
|
return defaultDestNcPath(sourceNcPath, format);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSafeDestination(destNcPath, msg) {
|
||||||
|
const dest = normalizeNcPath(destNcPath);
|
||||||
|
const allowedPrefix = normalizeNcPath(DEFAULT_OUTPUT_FOLDER);
|
||||||
|
if (msg.allowOverwrite === true) {
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
if (!dest.startsWith(allowedPrefix)) {
|
||||||
|
throw new Error(
|
||||||
|
`destNcPath "${dest}" ligger utanför ${allowedPrefix}. Sätt msg.allowOverwrite=true för att tillåta.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig(node) {
|
||||||
|
const cfg = node.configNode;
|
||||||
|
if (!cfg) {
|
||||||
|
throw new Error('Ingen nc-collab-config kopplad');
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFromPath(ncPath) {
|
||||||
|
return basenameFromNcPath(ncPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickMsgField(msg, ...keys) {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (msg[key] !== undefined && msg[key] !== null && msg[key] !== '') {
|
||||||
|
return msg[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setError(node, msg, err) {
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
msg.error = error.message;
|
||||||
|
msg.payload = { error: error.message };
|
||||||
|
node.error(error.message, msg);
|
||||||
|
node.status({ fill: 'red', shape: 'ring', text: error.message.slice(0, 40) });
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSuccess(node, msg, text) {
|
||||||
|
node.status({ fill: 'green', shape: 'dot', text: text || 'ok' });
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStatus(node) {
|
||||||
|
node.status({});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_OUTPUT_FOLDER,
|
||||||
|
MIME_BY_EXT,
|
||||||
|
normalizeNcPath,
|
||||||
|
basenameFromNcPath,
|
||||||
|
replaceExtension,
|
||||||
|
contentTypeForFormat,
|
||||||
|
defaultDestNcPath,
|
||||||
|
resolveDestNcPath,
|
||||||
|
assertSafeDestination,
|
||||||
|
getConfig,
|
||||||
|
filenameFromPath,
|
||||||
|
pickMsgField,
|
||||||
|
setError,
|
||||||
|
setSuccess,
|
||||||
|
clearStatus
|
||||||
|
};
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { requestBuffer } = require('./http-client');
|
||||||
|
const { normalizeNcPath, filenameFromPath } = require('./utils');
|
||||||
|
|
||||||
|
function authHeader(config) {
|
||||||
|
const token = Buffer.from(`${config.username}:${config.password}`).toString('base64');
|
||||||
|
return `Basic ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webDavBase(config) {
|
||||||
|
const base = config.nextcloudUrl.replace(/\/$/, '');
|
||||||
|
return `${base}/remote.php/dav/files/${encodeURIComponent(config.username)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePathSegments(ncPath) {
|
||||||
|
const normalized = normalizeNcPath(ncPath);
|
||||||
|
return normalized
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function webDavUrl(config, ncPath) {
|
||||||
|
const rel = encodePathSegments(ncPath);
|
||||||
|
return rel ? `${webDavBase(config)}/${rel}` : webDavBase(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tlsOptions(config) {
|
||||||
|
return { rejectUnauthorized: config.verifyTls !== false && config.verifyTls !== 'false' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function webdavGet(config, ncPath) {
|
||||||
|
const url = webDavUrl(config, ncPath);
|
||||||
|
const res = await requestBuffer({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: authHeader(config) },
|
||||||
|
rejectUnauthorized: tlsOptions(config).rejectUnauthorized
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.statusCode === 404) {
|
||||||
|
throw new Error(`WebDAV 404: filen finns inte: ${normalizeNcPath(ncPath)}`);
|
||||||
|
}
|
||||||
|
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||||
|
throw new Error(`WebDAV ${res.statusCode}: kontrollera app-lösenord och användare`);
|
||||||
|
}
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw new Error(`WebDAV GET misslyckades (${res.statusCode}) för ${normalizeNcPath(ncPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: res.body,
|
||||||
|
filename: filenameFromPath(ncPath),
|
||||||
|
ncPath: normalizeNcPath(ncPath),
|
||||||
|
contentType: res.headers['content-type']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFolder(config, folderPath) {
|
||||||
|
const normalized = normalizeNcPath(folderPath);
|
||||||
|
if (!normalized || normalized === '/') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
let current = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
current += '/' + part;
|
||||||
|
const url = webDavUrl(config, current);
|
||||||
|
const res = await requestBuffer({
|
||||||
|
url,
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: { Authorization: authHeader(config) },
|
||||||
|
rejectUnauthorized: tlsOptions(config).rejectUnauthorized
|
||||||
|
});
|
||||||
|
if (res.statusCode !== 201 && res.statusCode !== 405 && res.statusCode !== 301) {
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
throw new Error(`WebDAV MKCOL misslyckades (${res.statusCode}) för ${current}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function webdavPut(config, ncPath, buffer, contentType) {
|
||||||
|
const dest = normalizeNcPath(ncPath);
|
||||||
|
const folder = dest.substring(0, dest.lastIndexOf('/')) || '/';
|
||||||
|
await ensureFolder(config, folder);
|
||||||
|
|
||||||
|
const url = webDavUrl(config, dest);
|
||||||
|
const res = await requestBuffer({
|
||||||
|
url,
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader(config),
|
||||||
|
'Content-Type': contentType || 'application/octet-stream',
|
||||||
|
'Content-Length': buffer.length
|
||||||
|
},
|
||||||
|
body: buffer,
|
||||||
|
rejectUnauthorized: tlsOptions(config).rejectUnauthorized
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.statusCode === 401 || res.statusCode === 403) {
|
||||||
|
throw new Error(`WebDAV ${res.statusCode}: kontrollera app-lösenord`);
|
||||||
|
}
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw new Error(`WebDAV PUT misslyckades (${res.statusCode}) för ${dest}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
destNcPath: dest,
|
||||||
|
filename: filenameFromPath(dest)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function webdavPropfindFileId(config, ncPath) {
|
||||||
|
const url = webDavUrl(config, ncPath);
|
||||||
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:prop><oc:fileid/></d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
|
||||||
|
const res = await requestBuffer({
|
||||||
|
url,
|
||||||
|
method: 'PROPFIND',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader(config),
|
||||||
|
Depth: '0',
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8'
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
rejectUnauthorized: tlsOptions(config).rejectUnauthorized
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw new Error(`PROPFIND misslyckades (${res.statusCode}) för ${normalizeNcPath(ncPath)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = res.body.toString('utf8');
|
||||||
|
const match = text.match(/<oc:fileid>(\d+)<\/oc:fileid>/i) || text.match(/<nc:fileid>(\d+)<\/nc:fileid>/i);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Kunde inte hitta fileId för ${normalizeNcPath(ncPath)}`);
|
||||||
|
}
|
||||||
|
return parseInt(match[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
webDavUrl,
|
||||||
|
webdavGet,
|
||||||
|
webdavPut,
|
||||||
|
webdavPropfindFileId,
|
||||||
|
ensureFolder
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script type="text/javascript">
|
||||||
|
RED.nodes.registerType('collabora-bridge', {
|
||||||
|
category: 'nextcloud collabora',
|
||||||
|
color: '#e67e22',
|
||||||
|
defaults: {
|
||||||
|
name: { value: '' },
|
||||||
|
config: { type: 'nc-collab-config', required: true },
|
||||||
|
operation: { value: 'ncFileToPdf' }
|
||||||
|
},
|
||||||
|
inputs: 1,
|
||||||
|
outputs: 1,
|
||||||
|
icon: 'font-awesome/fa-exchange',
|
||||||
|
label: function () {
|
||||||
|
return this.name || this.operation;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-template-name="collabora-bridge">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
|
||||||
|
<input type="text" id="node-input-name">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
|
||||||
|
<input type="text" id="node-input-config">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-operation"><i class="fa fa-wrench"></i> Pipeline</label>
|
||||||
|
<select id="node-input-operation">
|
||||||
|
<option value="ncFileToPdf">ncFileToPdf</option>
|
||||||
|
<option value="ncFileToPng">ncFileToPng</option>
|
||||||
|
<option value="ncFileToHtml">ncFileToHtml</option>
|
||||||
|
<option value="htmlToOffice">htmlToOffice</option>
|
||||||
|
<option value="fillTemplateSave">fillTemplateSave</option>
|
||||||
|
<option value="compareVersionsPdf">compareVersionsPdf</option>
|
||||||
|
<option value="extractToPayload">extractToPayload</option>
|
||||||
|
<option value="createAssetLink">createAssetLink</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-help-name="collabora-bridge">
|
||||||
|
<p>Agent-pipelines: NC-fil → konvertering → WebDAV PUT under <code>/Dokument/OfficeOut/</code>.</p>
|
||||||
|
<p>Skriver aldrig över original. Sätt <code>msg.allowOverwrite=true</code> för annan destNcPath.</p>
|
||||||
|
<p>Överstyr via <code>msg.operation</code>. Standardfält: <code>ncPath</code>, <code>destNcPath</code>, <code>format</code>, <code>fields</code>, <code>compareNcPath</code>.</p>
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const bridge = require('../lib/bridge');
|
||||||
|
const { getConfig, setError, setSuccess, clearStatus, pickMsgField } = require('../lib/utils');
|
||||||
|
|
||||||
|
const OPERATIONS = {
|
||||||
|
ncFileToPdf: bridge.ncFileToPdf,
|
||||||
|
ncFileToPng: bridge.ncFileToPng,
|
||||||
|
ncFileToHtml: bridge.ncFileToHtml,
|
||||||
|
htmlToOffice: bridge.htmlToOffice,
|
||||||
|
fillTemplateSave: bridge.fillTemplateSave,
|
||||||
|
compareVersionsPdf: bridge.compareVersionsPdf,
|
||||||
|
extractToPayload: bridge.extractToPayload,
|
||||||
|
createAssetLink: bridge.createAssetLink
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = function (RED) {
|
||||||
|
function CollaboraBridgeNode(config) {
|
||||||
|
RED.nodes.createNode(this, config);
|
||||||
|
const node = this;
|
||||||
|
node.configNode = RED.nodes.getNode(config.config);
|
||||||
|
node.operation = config.operation || 'ncFileToPdf';
|
||||||
|
|
||||||
|
node.on('input', async (msg, send, done) => {
|
||||||
|
send = send || function () { node.send.apply(node, arguments); };
|
||||||
|
done = done || function (err) { if (err) { node.error(err, msg); } };
|
||||||
|
clearStatus(node);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfg = getConfig(node);
|
||||||
|
const op = pickMsgField(msg, 'operation') || node.operation;
|
||||||
|
const fn = OPERATIONS[op];
|
||||||
|
if (!fn) {
|
||||||
|
throw new Error(`Okänd bridge-operation: ${op}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fn(cfg, msg);
|
||||||
|
Object.assign(msg, result);
|
||||||
|
if (result.payload !== undefined) {
|
||||||
|
msg.payload = result.payload;
|
||||||
|
} else {
|
||||||
|
msg.payload = {
|
||||||
|
destNcPath: result.destNcPath,
|
||||||
|
filename: result.filename,
|
||||||
|
format: result.format,
|
||||||
|
assetUrl: result.assetUrl,
|
||||||
|
fields: result.fields,
|
||||||
|
structure: result.structure
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(node, msg, op);
|
||||||
|
send(msg);
|
||||||
|
done();
|
||||||
|
} catch (err) {
|
||||||
|
setError(node, msg, err);
|
||||||
|
send(msg);
|
||||||
|
done(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
RED.nodes.registerType('collabora-bridge', CollaboraBridgeNode);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script type="text/javascript">
|
||||||
|
RED.nodes.registerType('collabora-convert', {
|
||||||
|
category: 'nextcloud collabora',
|
||||||
|
color: '#0082c9',
|
||||||
|
defaults: {
|
||||||
|
name: { value: '' },
|
||||||
|
config: { type: 'nc-collab-config', required: true },
|
||||||
|
operation: { value: 'convert' },
|
||||||
|
format: { value: 'pdf' }
|
||||||
|
},
|
||||||
|
inputs: 1,
|
||||||
|
outputs: 1,
|
||||||
|
icon: 'font-awesome/fa-file-pdf-o',
|
||||||
|
label: function () {
|
||||||
|
return this.name || `convert → ${this.format}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-template-name="collabora-convert">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
|
||||||
|
<input type="text" id="node-input-name">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
|
||||||
|
<input type="text" id="node-input-config">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-format"><i class="fa fa-file"></i> Format</label>
|
||||||
|
<select id="node-input-format">
|
||||||
|
<option value="pdf">pdf</option>
|
||||||
|
<option value="png">png</option>
|
||||||
|
<option value="docx">docx</option>
|
||||||
|
<option value="odt">odt</option>
|
||||||
|
<option value="html">html</option>
|
||||||
|
<option value="txt">txt</option>
|
||||||
|
<option value="xlsx">xlsx</option>
|
||||||
|
<option value="pptx">pptx</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-help-name="collabora-convert">
|
||||||
|
<p>Låg nivå: <code>/cool/convert-to</code> mot CODE.</p>
|
||||||
|
<p>Ingång: <code>msg.ncPath</code> eller <code>msg.payload</code> (Buffer). Valfritt <code>msg.format</code>, <code>msg.templateNcPath</code>, <code>msg.compareNcPath</code>.</p>
|
||||||
|
<p>Utdata: <code>msg.payload</code> (Buffer). Sparar inte till Nextcloud — använd bridge eller WebDAV PUT efteråt.</p>
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { getConfig, setError, setSuccess, clearStatus, pickMsgField } = require('../lib/utils');
|
||||||
|
const { convertTo } = require('../lib/collabora');
|
||||||
|
const { loadSourceBuffer } = require('../lib/bridge');
|
||||||
|
const { webdavGet } = require('../lib/webdav');
|
||||||
|
|
||||||
|
module.exports = function (RED) {
|
||||||
|
function CollaboraConvertNode(config) {
|
||||||
|
RED.nodes.createNode(this, config);
|
||||||
|
const node = this;
|
||||||
|
node.configNode = RED.nodes.getNode(config.config);
|
||||||
|
node.operation = config.operation || 'convert';
|
||||||
|
node.format = config.format || 'pdf';
|
||||||
|
|
||||||
|
node.on('input', async (msg, send, done) => {
|
||||||
|
send = send || function () { node.send.apply(node, arguments); };
|
||||||
|
done = done || function (err) { if (err) { node.error(err, msg); } };
|
||||||
|
clearStatus(node);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfg = getConfig(node);
|
||||||
|
const format = (pickMsgField(msg, 'format') || node.format).toLowerCase();
|
||||||
|
const source = await loadSourceBuffer(cfg, msg);
|
||||||
|
const extraFields = [];
|
||||||
|
|
||||||
|
if (msg.templateNcPath) {
|
||||||
|
const tpl = await webdavGet(cfg, msg.templateNcPath);
|
||||||
|
extraFields.push({ name: 'template', buffer: tpl.buffer, filename: tpl.filename });
|
||||||
|
}
|
||||||
|
if (msg.compareNcPath) {
|
||||||
|
const cmp = await webdavGet(cfg, msg.compareNcPath);
|
||||||
|
extraFields.push({ name: 'compare', buffer: cmp.buffer, filename: cmp.filename });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await convertTo(cfg, {
|
||||||
|
buffer: source.buffer,
|
||||||
|
filename: source.filename,
|
||||||
|
format,
|
||||||
|
extraFields
|
||||||
|
});
|
||||||
|
|
||||||
|
msg.payload = result.buffer;
|
||||||
|
msg.format = format;
|
||||||
|
msg.contentType = result.contentType;
|
||||||
|
msg.statusCode = result.statusCode;
|
||||||
|
msg.filename = source.filename;
|
||||||
|
setSuccess(node, msg, `convert → ${format}`);
|
||||||
|
send(msg);
|
||||||
|
done();
|
||||||
|
} catch (err) {
|
||||||
|
setError(node, msg, err);
|
||||||
|
send(msg);
|
||||||
|
done(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
RED.nodes.registerType('collabora-convert', CollaboraConvertNode);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script type="text/javascript">
|
||||||
|
RED.nodes.registerType('collabora-extract', {
|
||||||
|
category: 'nextcloud collabora',
|
||||||
|
color: '#0082c9',
|
||||||
|
defaults: {
|
||||||
|
name: { value: '' },
|
||||||
|
config: { type: 'nc-collab-config', required: true },
|
||||||
|
filter: { value: 'contentcontrol' }
|
||||||
|
},
|
||||||
|
inputs: 1,
|
||||||
|
outputs: 1,
|
||||||
|
icon: 'font-awesome/fa-sitemap',
|
||||||
|
label: function () {
|
||||||
|
return this.name || 'extract structure';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-template-name="collabora-extract">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
|
||||||
|
<input type="text" id="node-input-name">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
|
||||||
|
<input type="text" id="node-input-config">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-filter"><i class="fa fa-filter"></i> Filter</label>
|
||||||
|
<input type="text" id="node-input-filter" placeholder="contentcontrol">
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-help-name="collabora-extract">
|
||||||
|
<p><code>/cool/extract-document-structure</code></p>
|
||||||
|
<p>Ingång: <code>msg.ncPath</code> eller Buffer i <code>msg.payload</code>. Valfritt <code>msg.filter</code>.</p>
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { getConfig, setError, setSuccess, clearStatus, pickMsgField } = require('../lib/utils');
|
||||||
|
const { extractStructure } = require('../lib/collabora');
|
||||||
|
const { loadSourceBuffer } = require('../lib/bridge');
|
||||||
|
|
||||||
|
module.exports = function (RED) {
|
||||||
|
function CollaboraExtractNode(config) {
|
||||||
|
RED.nodes.createNode(this, config);
|
||||||
|
const node = this;
|
||||||
|
node.configNode = RED.nodes.getNode(config.config);
|
||||||
|
node.filter = config.filter || 'contentcontrol';
|
||||||
|
|
||||||
|
node.on('input', async (msg, send, done) => {
|
||||||
|
send = send || function () { node.send.apply(node, arguments); };
|
||||||
|
done = done || function (err) { if (err) { node.error(err, msg); } };
|
||||||
|
clearStatus(node);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfg = getConfig(node);
|
||||||
|
const source = await loadSourceBuffer(cfg, msg);
|
||||||
|
const filter = pickMsgField(msg, 'filter') || node.filter;
|
||||||
|
const structure = await extractStructure(cfg, {
|
||||||
|
buffer: source.buffer,
|
||||||
|
filename: source.filename,
|
||||||
|
filter
|
||||||
|
});
|
||||||
|
|
||||||
|
msg.payload = structure;
|
||||||
|
msg.structure = structure;
|
||||||
|
setSuccess(node, msg, 'extract');
|
||||||
|
send(msg);
|
||||||
|
done();
|
||||||
|
} catch (err) {
|
||||||
|
setError(node, msg, err);
|
||||||
|
send(msg);
|
||||||
|
done(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
RED.nodes.registerType('collabora-extract', CollaboraExtractNode);
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script type="text/javascript">
|
||||||
|
RED.nodes.registerType('collabora-transform', {
|
||||||
|
category: 'nextcloud collabora',
|
||||||
|
color: '#0082c9',
|
||||||
|
defaults: {
|
||||||
|
name: { value: '' },
|
||||||
|
config: { type: 'nc-collab-config', required: true }
|
||||||
|
},
|
||||||
|
inputs: 1,
|
||||||
|
outputs: 1,
|
||||||
|
icon: 'font-awesome/fa-magic',
|
||||||
|
label: function () {
|
||||||
|
return this.name || 'transform structure';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-template-name="collabora-transform">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
|
||||||
|
<input type="text" id="node-input-name">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
|
||||||
|
<input type="text" id="node-input-config">
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-help-name="collabora-transform">
|
||||||
|
<p><code>/cool/transform-document-structure</code></p>
|
||||||
|
<p>Ingång: källa via <code>msg.ncPath</code> eller <code>msg.payload</code>, plus <code>msg.transform</code> (JSON).</p>
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { getConfig, setError, setSuccess, clearStatus, pickMsgField } = require('../lib/utils');
|
||||||
|
const { transformStructure } = require('../lib/collabora');
|
||||||
|
const { loadSourceBuffer } = require('../lib/bridge');
|
||||||
|
|
||||||
|
module.exports = function (RED) {
|
||||||
|
function CollaboraTransformNode(config) {
|
||||||
|
RED.nodes.createNode(this, config);
|
||||||
|
const node = this;
|
||||||
|
node.configNode = RED.nodes.getNode(config.config);
|
||||||
|
|
||||||
|
node.on('input', async (msg, send, done) => {
|
||||||
|
send = send || function () { node.send.apply(node, arguments); };
|
||||||
|
done = done || function (err) { if (err) { node.error(err, msg); } };
|
||||||
|
clearStatus(node);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfg = getConfig(node);
|
||||||
|
const transform = pickMsgField(msg, 'transform') || msg.fields;
|
||||||
|
if (!transform) {
|
||||||
|
throw new Error('Ange msg.transform eller msg.fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await loadSourceBuffer(cfg, msg);
|
||||||
|
const result = await transformStructure(cfg, {
|
||||||
|
buffer: source.buffer,
|
||||||
|
filename: source.filename,
|
||||||
|
transform
|
||||||
|
});
|
||||||
|
|
||||||
|
msg.payload = result.buffer;
|
||||||
|
msg.contentType = result.contentType;
|
||||||
|
setSuccess(node, msg, 'transform');
|
||||||
|
send(msg);
|
||||||
|
done();
|
||||||
|
} catch (err) {
|
||||||
|
setError(node, msg, err);
|
||||||
|
send(msg);
|
||||||
|
done(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
RED.nodes.registerType('collabora-transform', CollaboraTransformNode);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script type="text/javascript">
|
||||||
|
RED.nodes.registerType('collabora-wopi', {
|
||||||
|
category: 'nextcloud collabora',
|
||||||
|
color: '#0082c9',
|
||||||
|
defaults: {
|
||||||
|
name: { value: '' },
|
||||||
|
config: { type: 'nc-collab-config', required: true },
|
||||||
|
operation: { value: 'capabilities' }
|
||||||
|
},
|
||||||
|
inputs: 1,
|
||||||
|
outputs: 1,
|
||||||
|
icon: 'font-awesome/fa-search',
|
||||||
|
label: function () {
|
||||||
|
return this.name || `wopi ${this.operation}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-template-name="collabora-wopi">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
|
||||||
|
<input type="text" id="node-input-name">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
|
||||||
|
<input type="text" id="node-input-config">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-operation"><i class="fa fa-wrench"></i> Operation</label>
|
||||||
|
<select id="node-input-operation">
|
||||||
|
<option value="capabilities">capabilities</option>
|
||||||
|
<option value="discovery">discovery</option>
|
||||||
|
<option value="buildEditor">buildEditor</option>
|
||||||
|
<option value="checkFileInfo">checkFileInfo (debug)</option>
|
||||||
|
<option value="getFile">getFile (debug)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-help-name="collabora-wopi">
|
||||||
|
<p>WOPI discovery/capabilities och editor-URL. Agenten ska nästan aldrig PutFile — använd WebDAV PUT.</p>
|
||||||
|
<p><code>buildEditor</code>: <code>msg.wopiSrc</code> + <code>msg.token</code> → <code>msg.editorUrl</code>.</p>
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { getConfig, setError, setSuccess, clearStatus } = require('../lib/utils');
|
||||||
|
const { getCapabilities, getDiscovery } = require('../lib/collabora');
|
||||||
|
const { requestBuffer } = require('../lib/http-client');
|
||||||
|
|
||||||
|
module.exports = function (RED) {
|
||||||
|
function CollaboraWopiNode(config) {
|
||||||
|
RED.nodes.createNode(this, config);
|
||||||
|
const node = this;
|
||||||
|
node.configNode = RED.nodes.getNode(config.config);
|
||||||
|
node.operation = config.operation || 'capabilities';
|
||||||
|
|
||||||
|
node.on('input', async (msg, send, done) => {
|
||||||
|
send = send || function () { node.send.apply(node, arguments); };
|
||||||
|
done = done || function (err) { if (err) { node.error(err, msg); } };
|
||||||
|
clearStatus(node);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfg = getConfig(node);
|
||||||
|
const op = msg.operation || node.operation;
|
||||||
|
|
||||||
|
if (op === 'capabilities') {
|
||||||
|
msg.payload = await getCapabilities(cfg);
|
||||||
|
} else if (op === 'discovery') {
|
||||||
|
msg.payload = await getDiscovery(cfg);
|
||||||
|
} else if (op === 'buildEditor') {
|
||||||
|
const publicBase = cfg.collaboraPublicUrl || cfg.collaboraUrl;
|
||||||
|
const wopiSrc = encodeURIComponent(msg.wopiSrc || msg.WOPISrc || '');
|
||||||
|
const token = encodeURIComponent(msg.token || msg.access_token || '');
|
||||||
|
if (!wopiSrc || !token) {
|
||||||
|
throw new Error('buildEditor kräver msg.wopiSrc och msg.token');
|
||||||
|
}
|
||||||
|
msg.editorUrl = `${publicBase}/browser/dist/cool.html?WOPISrc=${wopiSrc}&access_token=${token}`;
|
||||||
|
msg.payload = { editorUrl: msg.editorUrl };
|
||||||
|
} else if (op === 'checkFileInfo' || op === 'getFile') {
|
||||||
|
const wopiUrl = msg.wopiUrl;
|
||||||
|
if (!wopiUrl) {
|
||||||
|
throw new Error(`${op} kräver msg.wopiUrl (felsökning)`);
|
||||||
|
}
|
||||||
|
const token = msg.token || msg.access_token || '';
|
||||||
|
const res = await requestBuffer({
|
||||||
|
url: wopiUrl,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-WOPI-Override': op === 'getFile' ? 'GET' : undefined
|
||||||
|
},
|
||||||
|
rejectUnauthorized: cfg.verifyTls !== false
|
||||||
|
});
|
||||||
|
msg.statusCode = res.statusCode;
|
||||||
|
msg.payload = op === 'getFile' ? res.body : JSON.parse(res.body.toString('utf8'));
|
||||||
|
} else {
|
||||||
|
throw new Error(`Okänd wopi-operation: ${op}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(node, msg, op);
|
||||||
|
send(msg);
|
||||||
|
done();
|
||||||
|
} catch (err) {
|
||||||
|
setError(node, msg, err);
|
||||||
|
send(msg);
|
||||||
|
done(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
RED.nodes.registerType('collabora-wopi', CollaboraWopiNode);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"nextcloud-collabora": {
|
||||||
|
"nextcloud collabora": "Nextcloud Collabora"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script type="text/javascript">
|
||||||
|
RED.nodes.registerType('nc-collab-config', {
|
||||||
|
category: 'config',
|
||||||
|
defaults: {
|
||||||
|
name: { value: '' },
|
||||||
|
nextcloudUrl: { value: 'https://cloud.hannesahlin.se', required: true },
|
||||||
|
collaboraUrl: { value: 'http://host.docker.internal:9980', required: true },
|
||||||
|
collaboraPublicUrl: { value: '' },
|
||||||
|
username: { value: '', required: true },
|
||||||
|
verifyTls: { value: false }
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
password: { type: 'password' }
|
||||||
|
},
|
||||||
|
label: function () {
|
||||||
|
return this.name || `NC Collab (${this.username || '?'})`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-template-name="nc-collab-config">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-config-input-name"><i class="fa fa-tag"></i> Namn</label>
|
||||||
|
<input type="text" id="node-config-input-name" placeholder="t.ex. cloud.hannesahlin.se">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-config-input-nextcloudUrl"><i class="fa fa-cloud"></i> Nextcloud URL</label>
|
||||||
|
<input type="text" id="node-config-input-nextcloudUrl">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-config-input-collaboraUrl"><i class="fa fa-server"></i> Collabora intern URL</label>
|
||||||
|
<input type="text" id="node-config-input-collaboraUrl">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-config-input-collaboraPublicUrl"><i class="fa fa-globe"></i> Collabora publik URL (valfri)</label>
|
||||||
|
<input type="text" id="node-config-input-collaboraPublicUrl">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-config-input-username"><i class="fa fa-user"></i> Användare</label>
|
||||||
|
<input type="text" id="node-config-input-username">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-config-input-password"><i class="fa fa-key"></i> App-lösenord</label>
|
||||||
|
<input type="password" id="node-config-input-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-config-input-verifyTls"><i class="fa fa-lock"></i> Verifiera TLS</label>
|
||||||
|
<input type="checkbox" id="node-config-input-verifyTls" style="width:auto">
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-help-name="nc-collab-config">
|
||||||
|
<p>En instans per miljö. Använd <strong>app-lösenord</strong>, aldrig vanligt kontolösen.</p>
|
||||||
|
<p>Fält: <code>nextcloudUrl</code>, <code>collaboraUrl</code>, <code>username</code>, app-lösen, <code>verifyTls</code>.</p>
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = function (RED) {
|
||||||
|
function NcCollabConfigNode(n) {
|
||||||
|
RED.nodes.createNode(this, n);
|
||||||
|
this.nextcloudUrl = (n.nextcloudUrl || '').replace(/\/$/, '');
|
||||||
|
this.collaboraUrl = (n.collaboraUrl || '').replace(/\/$/, '');
|
||||||
|
this.collaboraPublicUrl = (n.collaboraPublicUrl || '').replace(/\/$/, '');
|
||||||
|
this.username = n.username || '';
|
||||||
|
this.verifyTls = n.verifyTls !== false && n.verifyTls !== 'false';
|
||||||
|
this.password = this.credentials.password || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
RED.nodes.registerType('nc-collab-config', NcCollabConfigNode, {
|
||||||
|
credentials: {
|
||||||
|
password: { type: 'password' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script type="text/javascript">
|
||||||
|
RED.nodes.registerType('nc-office', {
|
||||||
|
category: 'nextcloud collabora',
|
||||||
|
color: '#46ba61',
|
||||||
|
defaults: {
|
||||||
|
name: { value: '' },
|
||||||
|
config: { type: 'nc-collab-config', required: true },
|
||||||
|
operation: { value: 'templates:list' }
|
||||||
|
},
|
||||||
|
inputs: 1,
|
||||||
|
outputs: 1,
|
||||||
|
icon: 'font-awesome/fa-file-text-o',
|
||||||
|
label: function () {
|
||||||
|
return this.name || `nc-office ${this.operation}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-template-name="nc-office">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-name"><i class="fa fa-tag"></i> Namn</label>
|
||||||
|
<input type="text" id="node-input-name">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-config"><i class="fa fa-cog"></i> Config</label>
|
||||||
|
<input type="text" id="node-input-config">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="node-input-operation"><i class="fa fa-wrench"></i> Operation</label>
|
||||||
|
<select id="node-input-operation">
|
||||||
|
<option value="templates:list">templates:list</option>
|
||||||
|
<option value="templates:create">templates:create</option>
|
||||||
|
<option value="template:extractFields">template:extractFields</option>
|
||||||
|
<option value="template:fillFields">template:fillFields</option>
|
||||||
|
<option value="document:direct">document:direct</option>
|
||||||
|
<option value="document:public">document:public</option>
|
||||||
|
<option value="files:convert">files:convert</option>
|
||||||
|
<option value="assets:create">assets:create</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/html" data-help-name="nc-office">
|
||||||
|
<p>Richdocuments OCS — mallar, fält, direkt-URL, assets.</p>
|
||||||
|
<p>Överstyr via <code>msg.operation</code>. Gemensamma fält: <code>ncPath</code>, <code>fileId</code>, <code>destNcPath</code>, <code>fields</code>.</p>
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { getConfig, setError, setSuccess, clearStatus, pickMsgField } = require('../lib/utils');
|
||||||
|
const { ocsRequest } = require('../lib/ocs');
|
||||||
|
const bridge = require('../lib/bridge');
|
||||||
|
const { resolveFileId } = bridge;
|
||||||
|
const { webdavGet, webdavPut } = require('../lib/webdav');
|
||||||
|
const { convertTo } = require('../lib/collabora');
|
||||||
|
const {
|
||||||
|
resolveDestNcPath,
|
||||||
|
assertSafeDestination,
|
||||||
|
contentTypeForFormat,
|
||||||
|
normalizeNcPath
|
||||||
|
} = require('../lib/utils');
|
||||||
|
|
||||||
|
module.exports = function (RED) {
|
||||||
|
function NcOfficeNode(config) {
|
||||||
|
RED.nodes.createNode(this, config);
|
||||||
|
const node = this;
|
||||||
|
node.configNode = RED.nodes.getNode(config.config);
|
||||||
|
node.operation = config.operation || 'templates:list';
|
||||||
|
|
||||||
|
node.on('input', async (msg, send, done) => {
|
||||||
|
send = send || function () { node.send.apply(node, arguments); };
|
||||||
|
done = done || function (err) { if (err) { node.error(err, msg); } };
|
||||||
|
clearStatus(node);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cfg = getConfig(node);
|
||||||
|
const op = msg.operation || node.operation;
|
||||||
|
let result;
|
||||||
|
|
||||||
|
switch (op) {
|
||||||
|
case 'templates:list': {
|
||||||
|
const type = pickMsgField(msg, 'templateType') || 'document';
|
||||||
|
result = await ocsRequest(cfg, {
|
||||||
|
method: 'GET',
|
||||||
|
path: `/ocs/v2.php/apps/richdocuments/api/v1/templates/${type}`
|
||||||
|
});
|
||||||
|
msg.payload = result.data;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'templates:create': {
|
||||||
|
result = await ocsRequest(cfg, {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/ocs/v2.php/apps/richdocuments/api/v1/templates/new',
|
||||||
|
body: {
|
||||||
|
templateId: msg.templateId,
|
||||||
|
fileName: msg.fileName || msg.destName,
|
||||||
|
templatePath: msg.templatePath
|
||||||
|
}
|
||||||
|
});
|
||||||
|
msg.payload = result.data;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'template:extractFields': {
|
||||||
|
const fileId = await resolveFileId(cfg, msg);
|
||||||
|
result = await ocsRequest(cfg, {
|
||||||
|
method: 'GET',
|
||||||
|
path: `/ocs/v2.php/apps/richdocuments/api/v1/template/fields/extract/${fileId}`
|
||||||
|
});
|
||||||
|
msg.fileId = fileId;
|
||||||
|
msg.payload = result.data;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'template:fillFields': {
|
||||||
|
const fileId = await resolveFileId(cfg, msg);
|
||||||
|
const fields = msg.fields || msg.transform;
|
||||||
|
const body = { fields };
|
||||||
|
if (msg.destNcPath) {
|
||||||
|
body.destination = normalizeNcPath(msg.destNcPath).replace(/^\//, '');
|
||||||
|
}
|
||||||
|
result = await ocsRequest(cfg, {
|
||||||
|
method: 'POST',
|
||||||
|
path: `/ocs/v2.php/apps/richdocuments/api/v1/template/fields/fill/${fileId}`,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
msg.fileId = fileId;
|
||||||
|
msg.payload = result.data;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'document:direct': {
|
||||||
|
const fileId = await resolveFileId(cfg, msg);
|
||||||
|
result = await ocsRequest(cfg, {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/ocs/v2.php/apps/richdocuments/api/v1/document',
|
||||||
|
body: { fileid: fileId }
|
||||||
|
});
|
||||||
|
msg.fileId = fileId;
|
||||||
|
msg.editorUrl = result.data?.url || result.data;
|
||||||
|
msg.payload = { url: msg.editorUrl, fileId };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'document:public': {
|
||||||
|
if (!msg.shareToken) {
|
||||||
|
throw new Error('document:public kräver msg.shareToken');
|
||||||
|
}
|
||||||
|
result = await ocsRequest(cfg, {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/ocs/v2.php/apps/richdocuments/api/v1/document',
|
||||||
|
body: { shareToken: msg.shareToken }
|
||||||
|
});
|
||||||
|
msg.editorUrl = result.data?.url || result.data;
|
||||||
|
msg.payload = { url: msg.editorUrl };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'files:convert': {
|
||||||
|
const format = (pickMsgField(msg, 'format') || 'pdf').toLowerCase();
|
||||||
|
const source = msg.ncPath
|
||||||
|
? await webdavGet(cfg, msg.ncPath)
|
||||||
|
: { buffer: Buffer.from(msg.payload), filename: msg.filename || 'file.bin' };
|
||||||
|
const converted = await convertTo(cfg, {
|
||||||
|
buffer: source.buffer,
|
||||||
|
filename: source.filename,
|
||||||
|
format
|
||||||
|
});
|
||||||
|
const dest = assertSafeDestination(resolveDestNcPath(msg, msg.ncPath, format), msg);
|
||||||
|
const put = await webdavPut(cfg, dest, converted.buffer, contentTypeForFormat(format));
|
||||||
|
msg.destNcPath = put.destNcPath;
|
||||||
|
msg.payload = converted.buffer;
|
||||||
|
msg.format = format;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'assets:create': {
|
||||||
|
const link = await bridge.createAssetLink(cfg, msg);
|
||||||
|
msg.assetUrl = link.assetUrl;
|
||||||
|
msg.payload = { assetUrl: link.assetUrl, ncPath: link.ncPath };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Okänd nc-office operation: ${op}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(node, msg, op);
|
||||||
|
send(msg);
|
||||||
|
done();
|
||||||
|
} catch (err) {
|
||||||
|
setError(node, msg, err);
|
||||||
|
send(msg);
|
||||||
|
done(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
RED.nodes.registerType('nc-office', NcOfficeNode);
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "node-red-contrib-nextcloud-collabora",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Node-RED nodes for Nextcloud Office + Collabora CODE (WebDAV, OCS richdocuments, /cool REST)",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Hannes Ahlin",
|
||||||
|
"keywords": [
|
||||||
|
"node-red",
|
||||||
|
"nextcloud",
|
||||||
|
"collabora",
|
||||||
|
"richdocuments",
|
||||||
|
"webdav",
|
||||||
|
"wopi"
|
||||||
|
],
|
||||||
|
"node-red": {
|
||||||
|
"version": ">=3.0.0",
|
||||||
|
"nodes": {
|
||||||
|
"nc-collab-config": "nodes/nc-collab-config.js",
|
||||||
|
"collabora-convert": "nodes/collabora-convert.js",
|
||||||
|
"collabora-extract": "nodes/collabora-extract.js",
|
||||||
|
"collabora-transform": "nodes/collabora-transform.js",
|
||||||
|
"collabora-wopi": "nodes/collabora-wopi.js",
|
||||||
|
"nc-office": "nodes/nc-office.js",
|
||||||
|
"collabora-bridge": "nodes/collabora-bridge.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"form-data": "^4.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# package.sh — skapa tarball för överföring Windows → Ubuntu
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
NAME="nextcloud-collabora-nodered-1.0.0"
|
||||||
|
OUT="${ROOT}/artifacts/${NAME}.tar.gz"
|
||||||
|
|
||||||
|
mkdir -p "${ROOT}/artifacts"
|
||||||
|
tar -czf "${OUT}" \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='artifacts/*.tar.gz' \
|
||||||
|
--exclude='.env' \
|
||||||
|
-C "${ROOT}" .
|
||||||
|
|
||||||
|
echo "Skapad: ${OUT}"
|
||||||
|
echo "På Ubuntu: scp ${OUT} ncadmin@192.168.1.26:/home/ncadmin/"
|
||||||
|
echo " IMPORT_TARBALL=... ./install.sh"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bygger artifacts/Assistentmanual-Nextcloud-Collabora-noder.docx från docs/AGENT-MANUAL.md
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
MD="${ROOT}/docs/AGENT-MANUAL.md"
|
||||||
|
OUT="${ROOT}/artifacts/Assistentmanual-Nextcloud-Collabora-noder.docx"
|
||||||
|
|
||||||
|
if ! command -v pandoc >/dev/null 2>&1; then
|
||||||
|
echo "pandoc saknas — installera med: sudo apt install pandoc"
|
||||||
|
echo "Markdown-källa finns i: ${MD}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${ROOT}/artifacts"
|
||||||
|
pandoc "${MD}" -o "${OUT}"
|
||||||
|
echo "Skapad: ${OUT}"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
uiPort: process.env.PORT || 1881,
|
||||||
|
credentialSecret: process.env.NODERED_CREDENTIAL_SECRET || "byt-den-här-hemligheten-vid-produktion",
|
||||||
|
flowFile: "flows.json",
|
||||||
|
userDir: "/data",
|
||||||
|
logging: {
|
||||||
|
console: {
|
||||||
|
level: "info",
|
||||||
|
metrics: false,
|
||||||
|
audit: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user