Sync infra config: HA/Zabbix relocation, Immich storage move, new services
Accumulated uncommitted infra changes: - Caddyfile: repoint HA/Zabbix to 192.168.1.4/.3, add ~20 new site routes - Immich: move media to /mnt/smsg, enable CUDA ML, mem limits, rewrite backup.sh - Add service stacks: agap-mcp, anki, family, freshrss, iperf3, kanboard, linkwarden, qbittorrent, radicale, syncthing, vikunja, windows - openwebui: enable API keys; ollama: drop CPU fallback - seafile/zabbix: extra_hosts entries; matrix: add user juris - Remove pihole stack and stale wiki/migrate.py - Ignore marketplace-mcp (standalone repo) and linkwarden runtime data Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
adolf/.env
|
adolf/.env
|
||||||
seafile/.env
|
seafile/.env
|
||||||
|
marketplace-mcp/
|
||||||
|
|||||||
109
Caddyfile
109
Caddyfile
@@ -1,5 +1,11 @@
|
|||||||
|
{
|
||||||
|
servers {
|
||||||
|
protocols h1 h2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
haos.alogins.net {
|
haos.alogins.net {
|
||||||
reverse_proxy http://192.168.1.141:8123 {
|
reverse_proxy http://192.168.1.4:8123 {
|
||||||
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
header_up X-Forwarded-Proto {scheme}
|
header_up X-Forwarded-Proto {scheme}
|
||||||
@@ -16,7 +22,7 @@ doc.alogins.net {
|
|||||||
}
|
}
|
||||||
|
|
||||||
zb.alogins.net {
|
zb.alogins.net {
|
||||||
reverse_proxy localhost:81
|
reverse_proxy 192.168.1.4:81
|
||||||
}
|
}
|
||||||
|
|
||||||
wiki.alogins.net {
|
wiki.alogins.net {
|
||||||
@@ -27,6 +33,10 @@ wiki.alogins.net {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lt.alogins.net {
|
||||||
|
reverse_proxy localhost:4321
|
||||||
|
}
|
||||||
|
|
||||||
nn.alogins.net {
|
nn.alogins.net {
|
||||||
reverse_proxy localhost:5678
|
reverse_proxy localhost:5678
|
||||||
}
|
}
|
||||||
@@ -43,12 +53,41 @@ ai.alogins.net {
|
|||||||
reverse_proxy localhost:3125
|
reverse_proxy localhost:3125
|
||||||
}
|
}
|
||||||
|
|
||||||
|
news.alogins.net {
|
||||||
|
reverse_proxy localhost:8091
|
||||||
|
}
|
||||||
|
|
||||||
|
family.alogins.net {
|
||||||
|
reverse_proxy localhost:8099
|
||||||
|
}
|
||||||
|
|
||||||
|
lw.alogins.net {
|
||||||
|
reverse_proxy localhost:3012
|
||||||
|
}
|
||||||
|
|
||||||
|
todo.alogins.net {
|
||||||
|
reverse_proxy localhost:3457
|
||||||
|
}
|
||||||
|
|
||||||
|
ttt.alogins.net {
|
||||||
|
reverse_proxy localhost:3003
|
||||||
|
}
|
||||||
|
|
||||||
openpi.alogins.net {
|
openpi.alogins.net {
|
||||||
root * /home/alvis/tmp/files/pi05_droid
|
root * /home/alvis/tmp/files/pi05_droid
|
||||||
file_server browse
|
file_server browse
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dl.alogins.net {
|
||||||
|
@chiefx17 path /chief-x17.zip
|
||||||
|
handle @chiefx17 {
|
||||||
|
root * /mnt/misc/qbittorrent/downloads
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
vui3.alogins.net {
|
vui3.alogins.net {
|
||||||
@xhttp {
|
@xhttp {
|
||||||
@@ -69,6 +108,24 @@ vui3.alogins.net {
|
|||||||
respond 401
|
respond 401
|
||||||
}
|
}
|
||||||
|
|
||||||
|
o.alogins.net {
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy localhost:3078
|
||||||
|
}
|
||||||
|
handle /admin* {
|
||||||
|
reverse_proxy localhost:3080
|
||||||
|
}
|
||||||
|
handle /mlflow* {
|
||||||
|
reverse_proxy localhost:5000
|
||||||
|
}
|
||||||
|
handle /airflow* {
|
||||||
|
reverse_proxy localhost:8080
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy localhost:3079
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
vui4.alogins.net {
|
vui4.alogins.net {
|
||||||
reverse_proxy localhost:58959
|
reverse_proxy localhost:58959
|
||||||
}
|
}
|
||||||
@@ -116,6 +173,54 @@ lk.alogins.net {
|
|||||||
reverse_proxy localhost:7880
|
reverse_proxy localhost:7880
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lf.alogins.net {
|
||||||
|
reverse_proxy localhost:3200
|
||||||
|
}
|
||||||
|
|
||||||
|
llm.alogins.net {
|
||||||
|
reverse_proxy localhost:4000
|
||||||
|
}
|
||||||
|
|
||||||
|
overleaf.alogins.net {
|
||||||
|
reverse_proxy localhost:8089
|
||||||
|
}
|
||||||
|
|
||||||
|
voice.alogins.net {
|
||||||
|
reverse_proxy localhost:8882
|
||||||
|
}
|
||||||
|
|
||||||
|
iperf.alogins.net {
|
||||||
|
reverse_proxy localhost:8095
|
||||||
|
}
|
||||||
|
|
||||||
|
anki.alogins.net {
|
||||||
|
reverse_proxy localhost:8180
|
||||||
|
}
|
||||||
|
|
||||||
|
kb.alogins.net {
|
||||||
|
reverse_proxy localhost:4800
|
||||||
|
}
|
||||||
|
|
||||||
|
mood.alogins.net {
|
||||||
|
reverse_proxy localhost:5177
|
||||||
|
}
|
||||||
|
|
||||||
|
sync.alogins.net {
|
||||||
|
reverse_proxy localhost:8384
|
||||||
|
}
|
||||||
|
|
||||||
|
dav.alogins.net {
|
||||||
|
reverse_proxy localhost:5232
|
||||||
|
}
|
||||||
|
|
||||||
|
tor.alogins.net {
|
||||||
|
reverse_proxy localhost:8085
|
||||||
|
}
|
||||||
|
|
||||||
|
win.alogins.net {
|
||||||
|
reverse_proxy localhost:8006
|
||||||
|
}
|
||||||
|
|
||||||
localhost:8042 {
|
localhost:8042 {
|
||||||
reverse_proxy localhost:8041
|
reverse_proxy localhost:8041
|
||||||
tls internal
|
tls internal
|
||||||
|
|||||||
14
agap-mcp/Dockerfile
Normal file
14
agap-mcp/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm install -g @bitwarden/cli
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY start.sh ./
|
||||||
|
RUN chmod +x start.sh
|
||||||
|
CMD ["./start.sh"]
|
||||||
27
agap-mcp/docker-compose.yml
Normal file
27
agap-mcp/docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
agap-mcp:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- PORT=3100
|
||||||
|
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
- HTTPS_PROXY=
|
||||||
|
- HTTP_PROXY=
|
||||||
|
- ALL_PROXY=
|
||||||
|
- https_proxy=
|
||||||
|
- http_proxy=
|
||||||
|
- all_proxy=
|
||||||
|
- BITWARDENCLI_APPDATA_DIR=/bw-data
|
||||||
|
- BW_EMAIL=allogn@gmail.com
|
||||||
|
- BW_PASSWORD=${BW_PASSWORD}
|
||||||
|
- VAULTWARDEN_URL=http://localhost:8041
|
||||||
|
- GITEA_URL=http://localhost:3000
|
||||||
|
- HA_URL=http://192.168.1.4:8123
|
||||||
|
- ZABBIX_URL=http://192.168.1.4:81
|
||||||
|
- RADICALE_URL=http://localhost:5232
|
||||||
|
- RADICALE_USER=alvis
|
||||||
|
- KANBOARD_URL=http://localhost:4800
|
||||||
|
- KANBOARD_BOT_USER=claude
|
||||||
|
volumes:
|
||||||
|
- /home/alvis/.config/Bitwarden CLI:/bw-data
|
||||||
10
agap-mcp/package.json
Normal file
10
agap-mcp/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "agap-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"express": "^4.19.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
agap-mcp/src/gitea.js
Normal file
73
agap-mcp/src/gitea.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const BASE = () => process.env.GITEA_URL || 'http://localhost:3000';
|
||||||
|
let _token = null;
|
||||||
|
|
||||||
|
export function initGitea(token) {
|
||||||
|
_token = token;
|
||||||
|
console.log('Gitea: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
if (!_token) throw new Error('Gitea not initialized');
|
||||||
|
return _token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(`${BASE()}/api/v1${path}`, {
|
||||||
|
...opts,
|
||||||
|
headers: { Authorization: `token ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaListRepos() {
|
||||||
|
return api('/repos/search?limit=50').then(r => r.data.map(r => ({
|
||||||
|
name: r.full_name, description: r.description, stars: r.stars_count,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaReadFile(repo, path, ref = 'HEAD') {
|
||||||
|
const data = await api(`/repos/${repo}/contents/${path}?ref=${ref}`);
|
||||||
|
return Buffer.from(data.content, 'base64').toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaWikiList(repo = 'alvis/AgapHost') {
|
||||||
|
const data = await api(`/repos/${repo}/wiki/pages?limit=50`);
|
||||||
|
return data.map(p => ({ name: p.title, updated: p.last_commit?.created }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaWikiRead(page, repo = 'alvis/AgapHost') {
|
||||||
|
const data = await api(`/repos/${repo}/wiki/page/${encodeURIComponent(page)}`);
|
||||||
|
return Buffer.from(data.content_base64, 'base64').toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaWikiWrite(page, content, message, repo = 'alvis/AgapHost') {
|
||||||
|
const dir = join(tmpdir(), 'agap-mcp-wiki');
|
||||||
|
const wikiUrl = `${BASE().replace('http://', `http://alvis:${token()}@`)}/alvis/AgapHost.wiki.git`;
|
||||||
|
|
||||||
|
const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'agap-mcp', GIT_AUTHOR_EMAIL: 'allogn@gmail.com', GIT_COMMITTER_NAME: 'agap-mcp', GIT_COMMITTER_EMAIL: 'allogn@gmail.com' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`git -C ${dir} pull ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' });
|
||||||
|
} catch {
|
||||||
|
execSync(`git clone ${wikiUrl} ${dir}`, { env: gitEnv, stdio: 'pipe' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = join(dir, `${page}.md`);
|
||||||
|
writeFileSync(file, content);
|
||||||
|
execSync(`git -C ${dir} add "${page}.md"`, { env: gitEnv, stdio: 'pipe' });
|
||||||
|
execSync(`git -C ${dir} commit -m "${message || `Update ${page}`}"`, { env: gitEnv, stdio: 'pipe' });
|
||||||
|
execSync(`git -C ${dir} push ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' });
|
||||||
|
return `${page} updated`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaListIssues(repo, state = 'open') {
|
||||||
|
return api(`/repos/${repo}/issues?state=${state}&type=issues&limit=50`).then(issues =>
|
||||||
|
issues.map(i => ({ number: i.number, title: i.title, state: i.state, labels: i.labels.map(l => l.name) }))
|
||||||
|
);
|
||||||
|
}
|
||||||
45
agap-mcp/src/homeassistant.js
Normal file
45
agap-mcp/src/homeassistant.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const BASE = () => process.env.HA_URL || 'http://192.168.1.4:8123';
|
||||||
|
let _token = null;
|
||||||
|
|
||||||
|
export function initHA(token) {
|
||||||
|
_token = token;
|
||||||
|
console.log('Home Assistant: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
if (!_token) throw new Error('HA not initialized');
|
||||||
|
return _token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(`${BASE()}/api${path}`, {
|
||||||
|
...opts,
|
||||||
|
headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HA API ${path}: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function haGetState(entityId) {
|
||||||
|
const s = await api(`/states/${entityId}`);
|
||||||
|
return { entity_id: s.entity_id, state: s.state, attributes: s.attributes, last_changed: s.last_changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function haListEntities(domain) {
|
||||||
|
const states = await api('/states');
|
||||||
|
const filtered = domain ? states.filter(s => s.entity_id.startsWith(`${domain}.`)) : states;
|
||||||
|
return filtered.map(s => ({ entity_id: s.entity_id, state: s.state, friendly_name: s.attributes.friendly_name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function haCallService(domain, service, data = {}) {
|
||||||
|
return api(`/services/${domain}/${service}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function haGetHistory(entityId, hours = 24) {
|
||||||
|
const start = new Date(Date.now() - hours * 3600 * 1000).toISOString();
|
||||||
|
const data = await api(`/history/period/${start}?filter_entity_id=${entityId}&minimal_response=true`);
|
||||||
|
return data[0] || [];
|
||||||
|
}
|
||||||
215
agap-mcp/src/kanboard.js
Normal file
215
agap-mcp/src/kanboard.js
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
const BASE = () => process.env.KANBOARD_URL || 'http://localhost:4800';
|
||||||
|
const BOT_USER = () => process.env.KANBOARD_BOT_USER || 'claude';
|
||||||
|
|
||||||
|
let _token = null;
|
||||||
|
let _botId = null;
|
||||||
|
|
||||||
|
export function initKanboard(token) {
|
||||||
|
_token = token;
|
||||||
|
console.log('Kanboard: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
if (!_token) throw new Error('Kanboard not initialized');
|
||||||
|
return _token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON-RPC over the app-wide token (login "jsonrpc:<token>") — full access to all projects.
|
||||||
|
async function rpc(method, params = {}) {
|
||||||
|
const auth = Buffer.from(`jsonrpc:${token()}`).toString('base64');
|
||||||
|
const res = await fetch(`${BASE()}/jsonrpc.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', method, id: 1, params }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Kanboard ${method}: ${res.status} ${await res.text()}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.error) throw new Error(`Kanboard ${method}: ${json.error.message || JSON.stringify(json.error)}`);
|
||||||
|
return json.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve + cache the bot user's id (default author for writes, filter for "my tasks").
|
||||||
|
async function botUserId() {
|
||||||
|
if (_botId != null) return _botId;
|
||||||
|
const user = await rpc('getUserByName', { username: BOT_USER() });
|
||||||
|
if (!user || !user.id) throw new Error(`Bot user not found in Kanboard: ${BOT_USER()}`);
|
||||||
|
_botId = parseInt(user.id);
|
||||||
|
return _botId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS = { open: 1, closed: 0 };
|
||||||
|
|
||||||
|
// --- Read / monitor ---
|
||||||
|
|
||||||
|
export async function kbListProjects() {
|
||||||
|
const projects = await rpc('getAllProjects');
|
||||||
|
return projects.map(p => ({
|
||||||
|
id: parseInt(p.id), name: p.name, is_active: parseInt(p.is_active),
|
||||||
|
is_private: parseInt(p.is_private), owner_id: parseInt(p.owner_id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbGetProject(projectId) {
|
||||||
|
const [project, columns, swimlanes] = await Promise.all([
|
||||||
|
rpc('getProjectById', { project_id: projectId }),
|
||||||
|
rpc('getColumns', { project_id: projectId }),
|
||||||
|
rpc('getAllSwimlanes', { project_id: projectId }),
|
||||||
|
]);
|
||||||
|
if (!project) throw new Error(`Project not found: ${projectId}`);
|
||||||
|
return {
|
||||||
|
id: parseInt(project.id), name: project.name, description: project.description,
|
||||||
|
is_active: parseInt(project.is_active), owner_id: parseInt(project.owner_id),
|
||||||
|
columns: columns.map(c => ({ id: parseInt(c.id), title: c.title, position: parseInt(c.position) })),
|
||||||
|
swimlanes: swimlanes.map(s => ({ id: parseInt(s.id), name: s.name, is_active: parseInt(s.is_active) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactTask(t) {
|
||||||
|
return {
|
||||||
|
id: parseInt(t.id), title: t.title, project_id: parseInt(t.project_id),
|
||||||
|
column_id: parseInt(t.column_id), swimlane_id: parseInt(t.swimlane_id),
|
||||||
|
owner_id: parseInt(t.owner_id), is_active: parseInt(t.is_active),
|
||||||
|
color_id: t.color_id, priority: parseInt(t.priority || 0),
|
||||||
|
date_due: parseInt(t.date_due) || 0, reference: t.reference || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbListTasks(projectId, status = 'open') {
|
||||||
|
let tasks = [];
|
||||||
|
if (status === 'all') {
|
||||||
|
const [open, closed] = await Promise.all([
|
||||||
|
rpc('getAllTasks', { project_id: projectId, status_id: STATUS.open }),
|
||||||
|
rpc('getAllTasks', { project_id: projectId, status_id: STATUS.closed }),
|
||||||
|
]);
|
||||||
|
tasks = [...open, ...closed];
|
||||||
|
} else {
|
||||||
|
tasks = await rpc('getAllTasks', { project_id: projectId, status_id: STATUS[status] ?? STATUS.open });
|
||||||
|
}
|
||||||
|
return tasks.map(compactTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks assigned to the bot across every project. status: open | closed | all
|
||||||
|
export async function kbMyTasks(status = 'open') {
|
||||||
|
const botId = await botUserId();
|
||||||
|
const projects = await rpc('getAllProjects');
|
||||||
|
const out = [];
|
||||||
|
for (const p of projects) {
|
||||||
|
const tasks = await kbListTasks(parseInt(p.id), status);
|
||||||
|
for (const t of tasks) {
|
||||||
|
if (t.owner_id === botId) out.push({ ...t, project_name: p.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbGetTask(taskId) {
|
||||||
|
const task = await rpc('getTask', { task_id: taskId });
|
||||||
|
if (!task) throw new Error(`Task not found: ${taskId}`);
|
||||||
|
const [subtasks, comments] = await Promise.all([
|
||||||
|
rpc('getAllSubtasks', { task_id: taskId }),
|
||||||
|
rpc('getAllComments', { task_id: taskId }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
task,
|
||||||
|
subtasks: (subtasks || []).map(s => ({
|
||||||
|
id: parseInt(s.id), title: s.title, status: parseInt(s.status),
|
||||||
|
status_name: s.status_name, user_id: parseInt(s.user_id) || 0, assignee: s.name || s.username || null,
|
||||||
|
})),
|
||||||
|
comments: (comments || []).map(c => ({
|
||||||
|
id: parseInt(c.id), user_id: parseInt(c.user_id), author: c.name || c.username,
|
||||||
|
date: parseInt(c.date_creation), content: c.comment,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbSearchTasks(projectId, query) {
|
||||||
|
const tasks = await rpc('searchTasks', { project_id: projectId, query });
|
||||||
|
return (tasks || []).map(compactTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbListUsers() {
|
||||||
|
const users = await rpc('getAllUsers');
|
||||||
|
return users.map(u => ({
|
||||||
|
id: parseInt(u.id), username: u.username, name: u.name,
|
||||||
|
role: u.role, is_active: parseInt(u.is_active), email: u.email,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbProjectActivity(projectId) {
|
||||||
|
return rpc('getProjectActivity', { project_id: projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Write (creation/comments authored by the bot user) ---
|
||||||
|
|
||||||
|
export async function kbCreateTask(params) {
|
||||||
|
const body = { ...params };
|
||||||
|
if (body.creator_id == null) body.creator_id = await botUserId();
|
||||||
|
const id = await rpc('createTask', body);
|
||||||
|
if (!id) throw new Error('createTask returned false (check required fields: title, project_id)');
|
||||||
|
return { task_id: parseInt(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbUpdateTask(params) {
|
||||||
|
const ok = await rpc('updateTask', params);
|
||||||
|
return { updated: !!ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move a card. project_id and swimlane_id are resolved from the task when omitted.
|
||||||
|
export async function kbMoveTask({ task_id, column_id, position = 1, swimlane_id, project_id }) {
|
||||||
|
if (project_id == null || swimlane_id == null) {
|
||||||
|
const task = await rpc('getTask', { task_id });
|
||||||
|
if (!task) throw new Error(`Task not found: ${task_id}`);
|
||||||
|
if (project_id == null) project_id = parseInt(task.project_id);
|
||||||
|
if (swimlane_id == null) swimlane_id = parseInt(task.swimlane_id);
|
||||||
|
}
|
||||||
|
const ok = await rpc('moveTaskPosition', { project_id, task_id, column_id, position, swimlane_id });
|
||||||
|
return { moved: !!ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbChangeTaskStatus(taskId, action) {
|
||||||
|
const method = action === 'close' ? 'closeTask' : 'openTask';
|
||||||
|
const ok = await rpc(method, { task_id: taskId });
|
||||||
|
return { [action === 'close' ? 'closed' : 'opened']: !!ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign a task to a user (username or numeric id).
|
||||||
|
export async function kbAssignTask(taskId, owner) {
|
||||||
|
let ownerId = owner;
|
||||||
|
if (typeof owner === 'string' && !/^\d+$/.test(owner)) {
|
||||||
|
const user = await rpc('getUserByName', { username: owner });
|
||||||
|
if (!user || !user.id) throw new Error(`User not found: ${owner}`);
|
||||||
|
ownerId = parseInt(user.id);
|
||||||
|
}
|
||||||
|
const ok = await rpc('updateTask', { id: taskId, owner_id: parseInt(ownerId) });
|
||||||
|
return { assigned: !!ok, owner_id: parseInt(ownerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbAddComment(taskId, content, userId) {
|
||||||
|
const uid = userId != null ? userId : await botUserId();
|
||||||
|
const id = await rpc('createComment', { task_id: taskId, user_id: uid, content });
|
||||||
|
if (!id) throw new Error('createComment returned false');
|
||||||
|
return { comment_id: parseInt(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbCreateSubtask(params) {
|
||||||
|
const body = { ...params };
|
||||||
|
if (body.user_id == null) body.user_id = await botUserId();
|
||||||
|
const id = await rpc('createSubtask', body);
|
||||||
|
if (!id) throw new Error('createSubtask returned false (required: task_id, title)');
|
||||||
|
return { subtask_id: parseInt(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbUpdateSubtask(params) {
|
||||||
|
const ok = await rpc('updateSubtask', params);
|
||||||
|
return { updated: !!ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbRemoveTask(taskId) {
|
||||||
|
const ok = await rpc('removeTask', { task_id: taskId });
|
||||||
|
return { removed: !!ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbRemoveComment(commentId) {
|
||||||
|
const ok = await rpc('removeComment', { comment_id: commentId });
|
||||||
|
return { removed: !!ok };
|
||||||
|
}
|
||||||
198
agap-mcp/src/radicale.js
Normal file
198
agap-mcp/src/radicale.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
const BASE = () => (process.env.RADICALE_URL || 'http://localhost:5232').replace(/\/$/, '');
|
||||||
|
const DEFAULT_USER = () => process.env.RADICALE_USER || 'alvis';
|
||||||
|
|
||||||
|
let _password = null;
|
||||||
|
|
||||||
|
export function initRadicale(password, _user) {
|
||||||
|
_password = password;
|
||||||
|
console.log('Radicale: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeader(user) {
|
||||||
|
if (!_password) throw new Error('Radicale not initialized');
|
||||||
|
const u = user || DEFAULT_USER();
|
||||||
|
return 'Basic ' + Buffer.from(`${u}:${_password}`).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dav(method, path, { user, headers = {}, body } = {}) {
|
||||||
|
const res = await fetch(`${BASE()}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: authHeader(user), ...headers },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok && res.status !== 207) {
|
||||||
|
throw new Error(`Radicale ${method} ${path}: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
return { status: res.status, text, headers: res.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function userPath(user) {
|
||||||
|
return `/${encodeURIComponent(user || DEFAULT_USER())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract <response> blocks; for each, pull href + displayname + resourcetype components
|
||||||
|
function parseMultistatus(xml) {
|
||||||
|
const responses = [];
|
||||||
|
const re = /<(?:[a-zA-Z0-9]+:)?response\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?response>/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(xml)) !== null) {
|
||||||
|
const block = m[1];
|
||||||
|
const href = (block.match(/<(?:[a-zA-Z0-9]+:)?href\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?href>/) || [])[1];
|
||||||
|
const displayname = (block.match(/<(?:[a-zA-Z0-9]+:)?displayname\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?displayname>/) || [])[1];
|
||||||
|
const isCalendar = /<(?:[a-zA-Z0-9]+:)?calendar\b/.test(block);
|
||||||
|
const isAddressbook = /<(?:[a-zA-Z0-9]+:)?addressbook\b/.test(block);
|
||||||
|
const isCollection = /<(?:[a-zA-Z0-9]+:)?collection\b/.test(block);
|
||||||
|
const getetag = (block.match(/<(?:[a-zA-Z0-9]+:)?getetag\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?getetag>/) || [])[1];
|
||||||
|
const componentSet = [];
|
||||||
|
const csRe = /<(?:[a-zA-Z0-9]+:)?comp\b[^>]*\sname="([^"]+)"/g;
|
||||||
|
let cm;
|
||||||
|
while ((cm = csRe.exec(block)) !== null) componentSet.push(cm[1]);
|
||||||
|
responses.push({ href, displayname, isCalendar, isAddressbook, isCollection, getetag, componentSet });
|
||||||
|
}
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROPFIND_COLLECTIONS = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/">
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<c:supported-calendar-component-set/>
|
||||||
|
<ic:calendar-color/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
|
||||||
|
const PROPFIND_EVENTS = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:">
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
|
||||||
|
export async function radicaleListCalendars(user) {
|
||||||
|
const { text } = await dav('PROPFIND', `${userPath(user)}/`, {
|
||||||
|
user,
|
||||||
|
headers: { Depth: '1', 'Content-Type': 'application/xml' },
|
||||||
|
body: PROPFIND_COLLECTIONS,
|
||||||
|
});
|
||||||
|
const items = parseMultistatus(text);
|
||||||
|
const out = [];
|
||||||
|
for (const r of items) {
|
||||||
|
if (!r.href || !r.isCalendar) continue;
|
||||||
|
// skip the parent (the user principal itself, which usually has no displayname)
|
||||||
|
const id = decodeURIComponent(r.href.replace(/\/$/, '').split('/').pop());
|
||||||
|
if (!id || id === (user || DEFAULT_USER())) continue;
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
displayname: r.displayname || null,
|
||||||
|
components: r.componentSet,
|
||||||
|
href: r.href,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIcsField(ics, field, { component = 'VEVENT' } = {}) {
|
||||||
|
// restrict search to the named component block (default VEVENT)
|
||||||
|
const block = (() => {
|
||||||
|
if (!component) return ics;
|
||||||
|
const re = new RegExp(`BEGIN:${component}([\\s\\S]*?)END:${component}`);
|
||||||
|
const m = ics.match(re);
|
||||||
|
return m ? m[1] : ics;
|
||||||
|
})();
|
||||||
|
const re = new RegExp(`(?:^|\\n)${field}(?:;[^:\\n]*)?:([^\\n\\r]*)`);
|
||||||
|
const m = block.match(re);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleListEvents(calendarId, user) {
|
||||||
|
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/`;
|
||||||
|
const { text } = await dav('PROPFIND', path, {
|
||||||
|
user,
|
||||||
|
headers: { Depth: '1', 'Content-Type': 'application/xml' },
|
||||||
|
body: PROPFIND_EVENTS,
|
||||||
|
});
|
||||||
|
const items = parseMultistatus(text);
|
||||||
|
const out = [];
|
||||||
|
for (const r of items) {
|
||||||
|
if (!r.href || !r.href.endsWith('.ics')) continue;
|
||||||
|
const filename = decodeURIComponent(r.href.split('/').pop());
|
||||||
|
// GET event to extract summary + start time
|
||||||
|
const ev = await dav('GET', r.href, { user });
|
||||||
|
const summary = parseIcsField(ev.text, 'SUMMARY');
|
||||||
|
const dtstart = parseIcsField(ev.text, 'DTSTART');
|
||||||
|
const dtend = parseIcsField(ev.text, 'DTEND');
|
||||||
|
const uid = parseIcsField(ev.text, 'UID');
|
||||||
|
out.push({ filename, uid, summary, dtstart, dtend, etag: r.getetag, href: r.href });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleGetEvent(calendarId, filename, user) {
|
||||||
|
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(filename)}`;
|
||||||
|
const { text } = await dav('GET', path, { user });
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleCreateCalendar({ displayname, color, components = ['VEVENT'], user, id }) {
|
||||||
|
if (!displayname) throw new Error('displayname required');
|
||||||
|
const calId = id || randomUUID();
|
||||||
|
const compXml = components.map(c => `<c:comp name="${c}"/>`).join('');
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/">
|
||||||
|
<d:set>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>${escapeXml(displayname)}</d:displayname>
|
||||||
|
<c:supported-calendar-component-set>${compXml}</c:supported-calendar-component-set>
|
||||||
|
${color ? `<ic:calendar-color>${escapeXml(color)}</ic:calendar-color>` : ''}
|
||||||
|
</d:prop>
|
||||||
|
</d:set>
|
||||||
|
</c:mkcalendar>`;
|
||||||
|
const path = `${userPath(user)}/${calId}/`;
|
||||||
|
await dav('MKCALENDAR', path, {
|
||||||
|
user,
|
||||||
|
headers: { 'Content-Type': 'application/xml' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return { id: calId, displayname, components };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleDeleteCalendar(calendarId, user) {
|
||||||
|
await dav('DELETE', `${userPath(user)}/${encodeURIComponent(calendarId)}/`, { user });
|
||||||
|
return { deleted: calendarId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicalePutEvent(calendarId, filename, ics, user) {
|
||||||
|
const name = filename.endsWith('.ics') ? filename : `${filename}.ics`;
|
||||||
|
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(name)}`;
|
||||||
|
await dav('PUT', path, {
|
||||||
|
user,
|
||||||
|
headers: { 'Content-Type': 'text/calendar; charset=utf-8' },
|
||||||
|
body: ics,
|
||||||
|
});
|
||||||
|
return { put: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleDeleteEvent(calendarId, filename, user) {
|
||||||
|
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(filename)}`;
|
||||||
|
await dav('DELETE', path, { user });
|
||||||
|
return { deleted: filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleMoveEvent({ fromCalendar, toCalendar, filename, user }) {
|
||||||
|
if (!fromCalendar || !toCalendar || !filename) {
|
||||||
|
throw new Error('fromCalendar, toCalendar, filename required');
|
||||||
|
}
|
||||||
|
const ics = await radicaleGetEvent(fromCalendar, filename, user);
|
||||||
|
await radicalePutEvent(toCalendar, filename, ics, user);
|
||||||
|
await radicaleDeleteEvent(fromCalendar, filename, user);
|
||||||
|
return { moved: filename, from: fromCalendar, to: toCalendar };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(s) {
|
||||||
|
return String(s).replace(/[<>&'"]/g, c => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]));
|
||||||
|
}
|
||||||
442
agap-mcp/src/server.js
Normal file
442
agap-mcp/src/server.js
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { initVaultwarden, vwGetPassword, vwGetItem, vwListItems, vwListOrgItems, vwCreateLogin, vwUpdatePassword } from './vaultwarden.js';
|
||||||
|
import { initGitea, giteaListRepos, giteaReadFile, giteaWikiList, giteaWikiRead, giteaWikiWrite, giteaListIssues } from './gitea.js';
|
||||||
|
import { initHA, haGetState, haListEntities, haCallService, haGetHistory } from './homeassistant.js';
|
||||||
|
import { initZabbix, zabbixGetProblems, zabbixGetHosts, zabbixGetItems, zabbixGetTriggers } from './zabbix.js';
|
||||||
|
import { initRadicale, radicaleListCalendars, radicaleListEvents, radicaleGetEvent, radicaleCreateCalendar, radicaleDeleteCalendar, radicalePutEvent, radicaleDeleteEvent, radicaleMoveEvent } from './radicale.js';
|
||||||
|
import { initKanboard, kbListProjects, kbGetProject, kbListTasks, kbMyTasks, kbGetTask, kbSearchTasks, kbListUsers, kbProjectActivity, kbCreateTask, kbUpdateTask, kbMoveTask, kbChangeTaskStatus, kbAssignTask, kbAddComment, kbCreateSubtask, kbUpdateSubtask, kbRemoveTask, kbRemoveComment } from './kanboard.js';
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || '3100');
|
||||||
|
|
||||||
|
// --- Init services ---
|
||||||
|
async function init() {
|
||||||
|
await initVaultwarden();
|
||||||
|
|
||||||
|
// Fetch all tokens from org to avoid "more than one result" on duplicates
|
||||||
|
const orgItems = vwListOrgItems();
|
||||||
|
const orgToken = (name) => {
|
||||||
|
const item = orgItems.find(i => i.name === name);
|
||||||
|
if (!item) throw new Error(`Token not found in org: ${name}`);
|
||||||
|
return item.login?.password;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gitea_token = orgToken('GITEA_TOKEN');
|
||||||
|
const ha_token = orgToken('HA_TOKEN');
|
||||||
|
const zabbix_token = orgToken('ZABBIX_TOKEN');
|
||||||
|
const radicale_password = orgToken('RADICALE_PASSWORD');
|
||||||
|
const kanboard_token = orgToken('KANBOARD_TOKEN');
|
||||||
|
|
||||||
|
initGitea(gitea_token);
|
||||||
|
initHA(ha_token);
|
||||||
|
initZabbix(zabbix_token);
|
||||||
|
initRadicale(radicale_password);
|
||||||
|
initKanboard(kanboard_token);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MCP Server factory (one per session — McpServer can't share transports) ---
|
||||||
|
function ok(text) {
|
||||||
|
return { content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function err(e) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServer() {
|
||||||
|
const server = new McpServer({ name: 'agap-mcp', version: '1.0.0' });
|
||||||
|
|
||||||
|
// --- Vaultwarden tools ---
|
||||||
|
server.tool('vw_get_password', 'Get password for a Vaultwarden item by name', { name: z.string() },
|
||||||
|
async ({ name }) => {
|
||||||
|
try { return ok(vwGetPassword(name)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('vw_get_item', 'Get full details of a Vaultwarden item (name, username, password, url, notes)', { name: z.string() },
|
||||||
|
async ({ name }) => {
|
||||||
|
try {
|
||||||
|
const item = vwGetItem(name);
|
||||||
|
return ok({ name: item.name, username: item.login?.username, password: item.login?.password, url: item.login?.uris?.[0]?.uri, notes: item.notes });
|
||||||
|
} catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('vw_list_items', 'List Vaultwarden items. Searches personal vault by default; set org=true to search org/AI collection', {
|
||||||
|
search: z.string().optional(),
|
||||||
|
org: z.boolean().optional(),
|
||||||
|
}, async ({ search, org }) => {
|
||||||
|
try {
|
||||||
|
const items = org ? vwListOrgItems(search) : vwListItems(search);
|
||||||
|
return ok(items.map(i => ({ id: i.id, name: i.name, username: i.login?.username, url: i.login?.uris?.[0]?.uri })));
|
||||||
|
} catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('vw_create_login', 'Create a new login item in Vaultwarden AI collection', {
|
||||||
|
name: z.string(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
password: z.string(),
|
||||||
|
url: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(vwCreateLogin(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('vw_update_password', 'Update the password of an existing Vaultwarden item', {
|
||||||
|
name: z.string().describe('Item name or ID'),
|
||||||
|
password: z.string(),
|
||||||
|
}, async ({ name, password }) => {
|
||||||
|
try { return ok(vwUpdatePassword(name, password)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Gitea tools ---
|
||||||
|
server.tool('gitea_list_repos', 'List all Gitea repositories', {},
|
||||||
|
async () => {
|
||||||
|
try { return ok(await giteaListRepos()); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('gitea_read_file', 'Read a file from a Gitea repository', {
|
||||||
|
repo: z.string().describe('e.g. alvis/AgapHost'),
|
||||||
|
path: z.string().describe('file path in repo'),
|
||||||
|
ref: z.string().optional().describe('branch/tag/commit, default HEAD'),
|
||||||
|
}, async ({ repo, path, ref }) => {
|
||||||
|
try { return ok(await giteaReadFile(repo, path, ref)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('gitea_wiki_list', 'List wiki pages', {
|
||||||
|
repo: z.string().optional().describe('default: alvis/AgapHost'),
|
||||||
|
}, async ({ repo }) => {
|
||||||
|
try { return ok(await giteaWikiList(repo)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('gitea_wiki_read', 'Read a wiki page', {
|
||||||
|
page: z.string(),
|
||||||
|
repo: z.string().optional().describe('default: alvis/AgapHost'),
|
||||||
|
}, async ({ page, repo }) => {
|
||||||
|
try { return ok(await giteaWikiRead(page, repo)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('gitea_wiki_write', 'Write/update a wiki page', {
|
||||||
|
page: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
message: z.string().optional().describe('commit message'),
|
||||||
|
repo: z.string().optional().describe('default: alvis/AgapHost'),
|
||||||
|
}, async ({ page, content, message, repo }) => {
|
||||||
|
try { return ok(await giteaWikiWrite(page, content, message, repo)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('gitea_list_issues', 'List issues for a Gitea repo', {
|
||||||
|
repo: z.string().describe('e.g. alvis/AgapHost'),
|
||||||
|
state: z.enum(['open', 'closed', 'all']).optional(),
|
||||||
|
}, async ({ repo, state }) => {
|
||||||
|
try { return ok(await giteaListIssues(repo, state)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Home Assistant tools ---
|
||||||
|
server.tool('ha_get_state', 'Get current state of a Home Assistant entity', {
|
||||||
|
entity_id: z.string().describe('e.g. light.living_room'),
|
||||||
|
}, async ({ entity_id }) => {
|
||||||
|
try { return ok(await haGetState(entity_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('ha_list_entities', 'List Home Assistant entities, optionally filtered by domain', {
|
||||||
|
domain: z.string().optional().describe('e.g. light, switch, sensor, binary_sensor'),
|
||||||
|
}, async ({ domain }) => {
|
||||||
|
try { return ok(await haListEntities(domain)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('ha_call_service', 'Call a Home Assistant service', {
|
||||||
|
domain: z.string().describe('e.g. light, switch, automation'),
|
||||||
|
service: z.string().describe('e.g. turn_on, turn_off, toggle'),
|
||||||
|
data: z.record(z.unknown()).optional().describe('service call data, e.g. {"entity_id": "light.x"}'),
|
||||||
|
}, async ({ domain, service, data }) => {
|
||||||
|
try { return ok(await haCallService(domain, service, data)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('ha_get_history', 'Get state history for a Home Assistant entity', {
|
||||||
|
entity_id: z.string(),
|
||||||
|
hours: z.number().optional().describe('hours of history, default 24'),
|
||||||
|
}, async ({ entity_id, hours }) => {
|
||||||
|
try { return ok(await haGetHistory(entity_id, hours)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Zabbix tools ---
|
||||||
|
server.tool('zabbix_get_problems', 'Get current active problems in Zabbix', {},
|
||||||
|
async () => {
|
||||||
|
try { return ok(await zabbixGetProblems()); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('zabbix_get_hosts', 'List all monitored hosts in Zabbix with availability status', {},
|
||||||
|
async () => {
|
||||||
|
try { return ok(await zabbixGetHosts()); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('zabbix_get_items', 'Get monitored items and their latest values for a Zabbix host', {
|
||||||
|
hostid: z.string().describe('Zabbix host ID'),
|
||||||
|
}, async ({ hostid }) => {
|
||||||
|
try { return ok(await zabbixGetItems(hostid)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('zabbix_get_triggers', 'Get triggers for a Zabbix host or all hosts', {
|
||||||
|
hostid: z.string().optional().describe('Zabbix host ID, omit for all hosts'),
|
||||||
|
}, async ({ hostid }) => {
|
||||||
|
try { return ok(await zabbixGetTriggers(hostid)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Radicale (CalDAV) tools ---
|
||||||
|
server.tool('radicale_list_calendars', 'List CalDAV calendars for a Radicale user (defaults to alvis)', {
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ user }) => {
|
||||||
|
try { return ok(await radicaleListCalendars(user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_list_events', 'List events in a Radicale calendar (returns filename, uid, summary, dtstart, dtend)', {
|
||||||
|
calendar_id: z.string().describe('Calendar UUID from radicale_list_calendars'),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ calendar_id, user }) => {
|
||||||
|
try { return ok(await radicaleListEvents(calendar_id, user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_get_event', 'Get raw iCalendar text for a single event', {
|
||||||
|
calendar_id: z.string(),
|
||||||
|
filename: z.string().describe('e.g. ABCD-1234.ics'),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ calendar_id, filename, user }) => {
|
||||||
|
try { return ok(await radicaleGetEvent(calendar_id, filename, user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_create_calendar', 'Create a new CalDAV calendar (MKCALENDAR). Returns the new calendar id.', {
|
||||||
|
displayname: z.string(),
|
||||||
|
color: z.string().optional().describe('e.g. #33CC66ff'),
|
||||||
|
components: z.array(z.string()).optional().describe('default ["VEVENT"]'),
|
||||||
|
id: z.string().optional().describe('explicit collection id; otherwise UUID generated'),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await radicaleCreateCalendar(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_delete_calendar', 'Delete a Radicale calendar collection', {
|
||||||
|
calendar_id: z.string(),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ calendar_id, user }) => {
|
||||||
|
try { return ok(await radicaleDeleteCalendar(calendar_id, user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_put_event', 'PUT an iCalendar event into a calendar (create or replace)', {
|
||||||
|
calendar_id: z.string(),
|
||||||
|
filename: z.string().describe('Event filename, with or without .ics'),
|
||||||
|
ics: z.string().describe('Full VCALENDAR body'),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ calendar_id, filename, ics, user }) => {
|
||||||
|
try { return ok(await radicalePutEvent(calendar_id, filename, ics, user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_delete_event', 'Delete an event from a calendar', {
|
||||||
|
calendar_id: z.string(),
|
||||||
|
filename: z.string(),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ calendar_id, filename, user }) => {
|
||||||
|
try { return ok(await radicaleDeleteEvent(calendar_id, filename, user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_move_event', 'Move an event between calendars (copy then delete original)', {
|
||||||
|
from_calendar: z.string(),
|
||||||
|
to_calendar: z.string(),
|
||||||
|
filename: z.string(),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ from_calendar, to_calendar, filename, user }) => {
|
||||||
|
try { return ok(await radicaleMoveEvent({ fromCalendar: from_calendar, toCalendar: to_calendar, filename, user })); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Kanboard tools ---
|
||||||
|
server.tool('kanboard_list_projects', 'List all Kanboard projects (id, name, active, owner)', {},
|
||||||
|
async () => {
|
||||||
|
try { return ok(await kbListProjects()); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_get_project', 'Get a Kanboard project with its columns and swimlanes (needed to move cards)', {
|
||||||
|
project_id: z.number().describe('Project ID'),
|
||||||
|
}, async ({ project_id }) => {
|
||||||
|
try { return ok(await kbGetProject(project_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_list_tasks', 'List tasks in a Kanboard project', {
|
||||||
|
project_id: z.number(),
|
||||||
|
status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'),
|
||||||
|
}, async ({ project_id, status }) => {
|
||||||
|
try { return ok(await kbListTasks(project_id, status)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_my_tasks', "List tasks assigned to the bot user (claude) across all projects", {
|
||||||
|
status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'),
|
||||||
|
}, async ({ status }) => {
|
||||||
|
try { return ok(await kbMyTasks(status)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_get_task', 'Get full detail of a Kanboard task, including its subtasks and comments', {
|
||||||
|
task_id: z.number(),
|
||||||
|
}, async ({ task_id }) => {
|
||||||
|
try { return ok(await kbGetTask(task_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_search_tasks', 'Search tasks in a project using Kanboard query syntax (e.g. "assignee:claude status:open", "due:today", "color:red")', {
|
||||||
|
project_id: z.number(),
|
||||||
|
query: z.string(),
|
||||||
|
}, async ({ project_id, query }) => {
|
||||||
|
try { return ok(await kbSearchTasks(project_id, query)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_list_users', 'List all Kanboard users (id, username, name, role)', {},
|
||||||
|
async () => {
|
||||||
|
try { return ok(await kbListUsers()); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_project_activity', 'Get the recent activity stream for a project (who did what)', {
|
||||||
|
project_id: z.number(),
|
||||||
|
}, async ({ project_id }) => {
|
||||||
|
try { return ok(await kbProjectActivity(project_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_create_task', 'Create a Kanboard task (authored by claude). At minimum provide title and project_id', {
|
||||||
|
title: z.string(),
|
||||||
|
project_id: z.number(),
|
||||||
|
description: z.string().optional().describe('Markdown'),
|
||||||
|
column_id: z.number().optional(),
|
||||||
|
owner_id: z.number().optional().describe('Assignee user id'),
|
||||||
|
color_id: z.string().optional().describe('e.g. yellow, blue, red, green'),
|
||||||
|
date_due: z.string().optional().describe('YYYY-MM-DD'),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
swimlane_id: z.number().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await kbCreateTask(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_update_task', 'Update fields of a Kanboard task', {
|
||||||
|
id: z.number().describe('Task id'),
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
owner_id: z.number().optional(),
|
||||||
|
color_id: z.string().optional(),
|
||||||
|
date_due: z.string().optional().describe('YYYY-MM-DD'),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
category_id: z.number().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await kbUpdateTask(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_move_task', 'Move a task to a column/position (e.g. to "Work in progress" or "Done"). project_id and swimlane_id are auto-resolved if omitted', {
|
||||||
|
task_id: z.number(),
|
||||||
|
column_id: z.number().describe('Target column id (see kanboard_get_project)'),
|
||||||
|
position: z.number().optional().describe('default 1 (top)'),
|
||||||
|
swimlane_id: z.number().optional(),
|
||||||
|
project_id: z.number().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await kbMoveTask(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_change_task_status', 'Open or close (mark done/archive) a task', {
|
||||||
|
task_id: z.number(),
|
||||||
|
action: z.enum(['open', 'close']),
|
||||||
|
}, async ({ task_id, action }) => {
|
||||||
|
try { return ok(await kbChangeTaskStatus(task_id, action)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_assign_task', 'Assign a task to a user (username or numeric id). Use "claude" to take it', {
|
||||||
|
task_id: z.number(),
|
||||||
|
owner: z.union([z.string(), z.number()]).describe('Username or user id'),
|
||||||
|
}, async ({ task_id, owner }) => {
|
||||||
|
try { return ok(await kbAssignTask(task_id, owner)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_add_comment', 'Add a comment to a task (authored by claude by default)', {
|
||||||
|
task_id: z.number(),
|
||||||
|
content: z.string().describe('Markdown'),
|
||||||
|
user_id: z.number().optional().describe('Override author; defaults to claude'),
|
||||||
|
}, async ({ task_id, content, user_id }) => {
|
||||||
|
try { return ok(await kbAddComment(task_id, content, user_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_create_subtask', 'Add a subtask to a task (good for tracking execution steps)', {
|
||||||
|
task_id: z.number(),
|
||||||
|
title: z.string(),
|
||||||
|
user_id: z.number().optional().describe('Assignee; defaults to claude'),
|
||||||
|
status: z.number().optional().describe('0=todo, 1=in progress, 2=done'),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await kbCreateSubtask(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_update_subtask', 'Update a subtask (e.g. mark in-progress or done)', {
|
||||||
|
id: z.number().describe('Subtask id'),
|
||||||
|
task_id: z.number(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
status: z.number().optional().describe('0=todo, 1=in progress, 2=done'),
|
||||||
|
user_id: z.number().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await kbUpdateSubtask(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_remove_task', 'Permanently delete a task (irreversible)', {
|
||||||
|
task_id: z.number(),
|
||||||
|
}, async ({ task_id }) => {
|
||||||
|
try { return ok(await kbRemoveTask(task_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_remove_comment', 'Permanently delete a comment (irreversible)', {
|
||||||
|
comment_id: z.number(),
|
||||||
|
}, async ({ comment_id }) => {
|
||||||
|
try { return ok(await kbRemoveComment(comment_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP server (Streamable HTTP + legacy SSE) ---
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const sseTransports = new Map();
|
||||||
|
|
||||||
|
// Streamable HTTP — stateless: fresh server per request, survives container restarts
|
||||||
|
app.all('/mcp', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
|
res.on('close', () => transport.close());
|
||||||
|
await createServer().connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('MCP request error:', e.message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legacy SSE — kept for backward compatibility
|
||||||
|
app.get('/sse', async (req, res) => {
|
||||||
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
|
sseTransports.set(transport.sessionId, transport);
|
||||||
|
res.on('close', () => sseTransports.delete(transport.sessionId));
|
||||||
|
await createServer().connect(transport);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/messages', async (req, res) => {
|
||||||
|
const transport = sseTransports.get(req.query.sessionId);
|
||||||
|
if (!transport) return res.status(400).send('Unknown session');
|
||||||
|
await transport.handlePostMessage(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/health', (_, res) => res.json({ status: 'ok', tools: 45 }));
|
||||||
|
|
||||||
|
init()
|
||||||
|
.then(() => {
|
||||||
|
app.listen(PORT, () => console.log(`agap-mcp listening on :${PORT}`));
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.error('Init failed:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
102
agap-mcp/src/vaultwarden.js
Normal file
102
agap-mcp/src/vaultwarden.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
|
||||||
|
const BW = 'bw';
|
||||||
|
const ORG_ID = '4bd75130-b4d3-48d4-a4cb-e52b70295a51';
|
||||||
|
const AI_COLLECTION = '5be27a82-8475-4c38-96b2-fa94ec8c957b';
|
||||||
|
|
||||||
|
let _session = null;
|
||||||
|
|
||||||
|
function bwEnv() {
|
||||||
|
const env = { ...process.env };
|
||||||
|
for (const k of ['HTTPS_PROXY','HTTP_PROXY','ALL_PROXY','https_proxy','http_proxy','all_proxy'])
|
||||||
|
delete env[k];
|
||||||
|
env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(args, input) {
|
||||||
|
return execFileSync(BW, args, {
|
||||||
|
env: bwEnv(),
|
||||||
|
encoding: 'utf8',
|
||||||
|
input,
|
||||||
|
stdio: input ? ['pipe','pipe','pipe'] : ['ignore','pipe','pipe'],
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initVaultwarden() {
|
||||||
|
const email = process.env.BW_EMAIL || 'allogn@gmail.com';
|
||||||
|
const password = process.env.BW_PASSWORD;
|
||||||
|
|
||||||
|
// Data dir is mounted from host — server already configured, skip bw config server
|
||||||
|
|
||||||
|
let status = 'unauthenticated';
|
||||||
|
try {
|
||||||
|
status = JSON.parse(run(['status'])).status;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated') {
|
||||||
|
run(['login', email, password, '--raw']);
|
||||||
|
}
|
||||||
|
|
||||||
|
_session = run(['unlock', password, '--raw']);
|
||||||
|
run(['sync', '--session', _session]);
|
||||||
|
console.log('Vaultwarden: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function session() {
|
||||||
|
if (!_session) throw new Error('Vaultwarden not initialized');
|
||||||
|
return _session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwGetPassword(name) {
|
||||||
|
return run(['get', 'password', name, '--session', session()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwGetItem(name) {
|
||||||
|
return JSON.parse(run(['get', 'item', name, '--session', session()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwListItems(search) {
|
||||||
|
const args = ['list', 'items', '--session', session()];
|
||||||
|
if (search) args.push('--search', search);
|
||||||
|
return JSON.parse(run(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwListOrgItems(search) {
|
||||||
|
const args = ['list', 'items', '--organizationid', ORG_ID, '--session', session()];
|
||||||
|
if (search) args.push('--search', search);
|
||||||
|
return JSON.parse(run(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwCreateLogin({ name, username, password, url, notes }) {
|
||||||
|
const item = {
|
||||||
|
organizationId: ORG_ID,
|
||||||
|
collectionIds: [AI_COLLECTION],
|
||||||
|
folderId: null,
|
||||||
|
type: 1,
|
||||||
|
name,
|
||||||
|
notes: notes || null,
|
||||||
|
favorite: false,
|
||||||
|
login: {
|
||||||
|
username: username || null,
|
||||||
|
password,
|
||||||
|
uris: url ? [{ match: null, uri: url }] : [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const encoded = run(['encode'], JSON.stringify(item));
|
||||||
|
return JSON.parse(run(['create', 'item', encoded, '--session', session()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwUpdatePassword(nameOrId, newPassword) {
|
||||||
|
let item;
|
||||||
|
try {
|
||||||
|
item = JSON.parse(run(['get', 'item', nameOrId, '--session', session()]));
|
||||||
|
} catch {
|
||||||
|
const items = vwListOrgItems(nameOrId);
|
||||||
|
item = items.find(i => i.name === nameOrId);
|
||||||
|
if (!item) throw new Error(`Item not found: ${nameOrId}`);
|
||||||
|
}
|
||||||
|
item.login.password = newPassword;
|
||||||
|
const encoded = run(['encode'], JSON.stringify(item));
|
||||||
|
return JSON.parse(run(['edit', 'item', item.id, encoded, '--session', session()]));
|
||||||
|
}
|
||||||
75
agap-mcp/src/zabbix.js
Normal file
75
agap-mcp/src/zabbix.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const BASE = () => `${process.env.ZABBIX_URL || 'http://localhost:81'}/api_jsonrpc.php`;
|
||||||
|
let _token = null;
|
||||||
|
let _reqId = 1;
|
||||||
|
|
||||||
|
export function initZabbix(token) {
|
||||||
|
_token = token;
|
||||||
|
console.log('Zabbix: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, params = {}) {
|
||||||
|
const res = await fetch(BASE(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${_token}` },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', method, params, id: _reqId++ }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) throw new Error(`Zabbix ${method}: ${data.error.data}`);
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zabbixGetProblems() {
|
||||||
|
const problems = await api('problem.get', {
|
||||||
|
output: ['eventid', 'name', 'severity', 'clock', 'acknowledged'],
|
||||||
|
selectAcknowledges: 'count',
|
||||||
|
recent: true,
|
||||||
|
sortfield: 'eventid',
|
||||||
|
sortorder: 'DESC',
|
||||||
|
});
|
||||||
|
return problems.map(p => ({
|
||||||
|
id: p.eventid,
|
||||||
|
name: p.name,
|
||||||
|
severity: ['Not classified','Information','Warning','Average','High','Disaster'][+p.severity],
|
||||||
|
time: new Date(+p.clock * 1000).toISOString(),
|
||||||
|
acknowledged: +p.acknowledged > 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zabbixGetHosts() {
|
||||||
|
return api('host.get', {
|
||||||
|
output: ['hostid', 'host', 'name', 'available', 'status'],
|
||||||
|
selectInterfaces: ['ip'],
|
||||||
|
}).then(hosts => hosts.map(h => ({
|
||||||
|
id: h.hostid,
|
||||||
|
name: h.name || h.host,
|
||||||
|
available: ['Unknown','Available','Unavailable'][+h.available] || 'Unknown',
|
||||||
|
enabled: h.status === '0',
|
||||||
|
ip: h.interfaces?.[0]?.ip,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zabbixGetItems(hostid) {
|
||||||
|
return api('item.get', {
|
||||||
|
output: ['itemid', 'name', 'key_', 'lastvalue', 'units', 'lastclock'],
|
||||||
|
hostids: hostid,
|
||||||
|
monitored: true,
|
||||||
|
sortfield: 'name',
|
||||||
|
}).then(items => items.map(i => ({
|
||||||
|
name: i.name,
|
||||||
|
key: i.key_,
|
||||||
|
value: i.lastvalue,
|
||||||
|
units: i.units,
|
||||||
|
updated: new Date(+i.lastclock * 1000).toISOString(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zabbixGetTriggers(hostid) {
|
||||||
|
const params = { output: ['triggerid','description','priority','value'], active: 1, monitored: 1 };
|
||||||
|
if (hostid) params.hostids = hostid;
|
||||||
|
return api('trigger.get', params).then(triggers => triggers.map(t => ({
|
||||||
|
id: t.triggerid,
|
||||||
|
name: t.description,
|
||||||
|
severity: ['Not classified','Information','Warning','Average','High','Disaster'][+t.priority],
|
||||||
|
problem: t.value === '1',
|
||||||
|
})));
|
||||||
|
}
|
||||||
2
agap-mcp/start.sh
Normal file
2
agap-mcp/start.sh
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
exec node src/server.js
|
||||||
11
anki/Dockerfile
Normal file
11
anki/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir anki
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
VOLUME /anki_data
|
||||||
|
|
||||||
|
ENV SYNC_BASE=/anki_data
|
||||||
|
|
||||||
|
CMD ["python", "-m", "anki.syncserver"]
|
||||||
17
anki/docker-compose.yml
Normal file
17
anki/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: anki
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
image: anki-sync-server:local
|
||||||
|
container_name: anki-sync-server
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8180:8080"
|
||||||
|
volumes:
|
||||||
|
- data:/anki_data
|
||||||
|
environment:
|
||||||
|
- SYNC_USER1=${ANKI_USER1:-admin:changeme}
|
||||||
|
- SYNC_BASE=/anki_data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
6
family/Dockerfile
Normal file
6
family/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM mediawiki:latest
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y ffmpeg unzip && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN curl -sL "https://extdist.wmflabs.org/dist/extensions/TimedMediaHandler-REL1_44-ef5edcf.tar.gz" \
|
||||||
|
| tar -xz -C /var/www/html/extensions/
|
||||||
BIN
family/IMG_0448.JPG
Normal file
BIN
family/IMG_0448.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
165
family/LocalSettings.php
Normal file
165
family/LocalSettings.php
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
# This file was automatically generated by the MediaWiki 1.45.3
|
||||||
|
# installer. If you make manual changes, please keep track in case you
|
||||||
|
# need to recreate them later.
|
||||||
|
#
|
||||||
|
# See includes/MainConfigSchema.php for all configurable settings
|
||||||
|
# and their default values, but don't forget to make changes in _this_
|
||||||
|
# file, not there.
|
||||||
|
#
|
||||||
|
# Further documentation for configuration settings may be found at:
|
||||||
|
# https://www.mediawiki.org/wiki/Manual:Configuration_settings
|
||||||
|
|
||||||
|
# Protect against web entry
|
||||||
|
if ( !defined( 'MEDIAWIKI' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Uncomment this to disable output compression
|
||||||
|
# $wgDisableOutputCompression = true;
|
||||||
|
|
||||||
|
$wgSitename = "РодоВики";
|
||||||
|
|
||||||
|
## The URL base path to the directory containing the wiki;
|
||||||
|
## defaults for all runtime URL paths are based off of this.
|
||||||
|
## For more information on customizing the URLs
|
||||||
|
## (like /w/index.php/Page_title to /wiki/Page_title) please see:
|
||||||
|
## https://www.mediawiki.org/wiki/Manual:Short_URL
|
||||||
|
$wgScriptPath = "";
|
||||||
|
|
||||||
|
## The protocol and server name to use in fully-qualified URLs
|
||||||
|
$wgServer = "https://family.alogins.net";
|
||||||
|
|
||||||
|
## The URL path to static resources (images, scripts, etc.)
|
||||||
|
$wgResourceBasePath = $wgScriptPath;
|
||||||
|
|
||||||
|
## The URL paths to the logo. Make sure you change this from the default,
|
||||||
|
## or else you'll overwrite your logo when you upgrade!
|
||||||
|
$wgLogos = [
|
||||||
|
'1x' => "$wgResourceBasePath/images/logo.jpg",
|
||||||
|
'icon' => "$wgResourceBasePath/images/logo.jpg",
|
||||||
|
];
|
||||||
|
|
||||||
|
## UPO means: this is also a user preference option
|
||||||
|
|
||||||
|
$wgEnableEmail = true;
|
||||||
|
$wgEnableUserEmail = true; # UPO
|
||||||
|
|
||||||
|
$wgEmergencyContact = "";
|
||||||
|
$wgPasswordSender = "";
|
||||||
|
|
||||||
|
$wgEnotifUserTalk = false; # UPO
|
||||||
|
$wgEnotifWatchlist = false; # UPO
|
||||||
|
$wgEmailAuthentication = true;
|
||||||
|
|
||||||
|
## Database settings
|
||||||
|
$wgDBtype = "mysql";
|
||||||
|
$wgDBserver = "db";
|
||||||
|
$wgDBname = "mediawiki";
|
||||||
|
$wgDBuser = "mw_k7px2q";
|
||||||
|
$wgDBpassword = "Vt9#mLqR4wXn8bZ2";
|
||||||
|
|
||||||
|
# MySQL specific settings
|
||||||
|
$wgDBprefix = "fw";
|
||||||
|
$wgDBssl = false;
|
||||||
|
|
||||||
|
# MySQL table options to use during installation or update
|
||||||
|
$wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary";
|
||||||
|
|
||||||
|
# Shared database table
|
||||||
|
# This has no effect unless $wgSharedDB is also set.
|
||||||
|
$wgSharedTables[] = "actor";
|
||||||
|
|
||||||
|
## Shared memory settings
|
||||||
|
$wgMainCacheType = CACHE_ACCEL;
|
||||||
|
$wgMemCachedServers = [];
|
||||||
|
|
||||||
|
## To enable image uploads, make sure the 'images' directory
|
||||||
|
## is writable, then set this to true:
|
||||||
|
$wgEnableUploads = true;
|
||||||
|
$wgMaxUploadSize = 20 * 1024 * 1024; // 20MB
|
||||||
|
$wgFileExtensions = array_merge( $wgFileExtensions, [
|
||||||
|
'png', 'gif', 'jpg', 'jpeg', 'webp',
|
||||||
|
'mp4', 'webm', 'ogv',
|
||||||
|
'mp3', 'ogg', 'oga', 'wav', 'flac',
|
||||||
|
] );
|
||||||
|
$wgUseImageMagick = true;
|
||||||
|
$wgImageMagickConvertCommand = "/usr/bin/convert";
|
||||||
|
|
||||||
|
# InstantCommons allows wiki to use images from https://commons.wikimedia.org
|
||||||
|
$wgUseInstantCommons = false;
|
||||||
|
|
||||||
|
# Periodically send a pingback to https://www.mediawiki.org/ with basic data
|
||||||
|
# about this MediaWiki instance. The Wikimedia Foundation shares this data
|
||||||
|
# with MediaWiki developers to help guide future development efforts.
|
||||||
|
$wgPingback = true;
|
||||||
|
|
||||||
|
# Site language code, should be one of the list in ./includes/languages/data/Names.php
|
||||||
|
$wgLanguageCode = "ru";
|
||||||
|
|
||||||
|
# Time zone
|
||||||
|
$wgLocaltimezone = "UTC";
|
||||||
|
|
||||||
|
## Set $wgCacheDirectory to a writable directory on the web server
|
||||||
|
## to make your wiki go slightly faster. The directory should not
|
||||||
|
## be publicly accessible from the web.
|
||||||
|
#$wgCacheDirectory = "$IP/cache";
|
||||||
|
|
||||||
|
$wgSecretKey = "5156b70f5486793efade503a2301caf53b9fe241505868a90e33509814fb7d1f";
|
||||||
|
|
||||||
|
# Changing this will log out all existing sessions.
|
||||||
|
$wgAuthenticationTokenVersion = "1";
|
||||||
|
|
||||||
|
# Bust ResourceLoader CSS cache
|
||||||
|
$wgCacheEpoch = '20260403000001';
|
||||||
|
|
||||||
|
# Site upgrade key. Must be set to a string (default provided) to turn on the
|
||||||
|
# web installer while LocalSettings.php is in place
|
||||||
|
$wgUpgradeKey = "da11c2f2774f498f";
|
||||||
|
|
||||||
|
## For attaching licensing metadata to pages, and displaying an
|
||||||
|
## appropriate copyright notice / icon. GNU Free Documentation
|
||||||
|
## License and Creative Commons licenses are supported so far.
|
||||||
|
$wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright
|
||||||
|
$wgRightsUrl = "";
|
||||||
|
$wgRightsText = "";
|
||||||
|
$wgRightsIcon = "";
|
||||||
|
|
||||||
|
# Path to the GNU diff3 utility. Used for conflict resolution.
|
||||||
|
$wgDiff3 = "/usr/bin/diff3";
|
||||||
|
|
||||||
|
## Default skin: you can change the default skin. Use the internal symbolic
|
||||||
|
## names, e.g. 'vector' or 'monobook':
|
||||||
|
$wgDefaultSkin = "vector-2022";
|
||||||
|
|
||||||
|
# Enabled skins.
|
||||||
|
# The following skins were automatically enabled:
|
||||||
|
wfLoadSkin( 'MinervaNeue' );
|
||||||
|
wfLoadSkin( 'MonoBook' );
|
||||||
|
wfLoadSkin( 'Timeless' );
|
||||||
|
wfLoadSkin( 'Vector' );
|
||||||
|
|
||||||
|
|
||||||
|
wfLoadExtension( 'Cite' );
|
||||||
|
wfLoadExtension( 'MultimediaViewer' );
|
||||||
|
wfLoadExtension( 'ParserFunctions' );
|
||||||
|
wfLoadExtension( 'VisualEditor' );
|
||||||
|
wfLoadExtension( 'TimedMediaHandler' );
|
||||||
|
|
||||||
|
$wgDefaultUserOptions['visualeditor-enable'] = 1;
|
||||||
|
$wgDefaultUserOptions['visualeditor-editor'] = 'visualeditor';
|
||||||
|
$wgVisualEditorParsoidAutoConfig = true;
|
||||||
|
|
||||||
|
# End of automatically generated settings.
|
||||||
|
# Add more configuration options below.
|
||||||
|
|
||||||
|
# Restrict all access to logged-in users only
|
||||||
|
$wgGroupPermissions['*']['read'] = false;
|
||||||
|
$wgGroupPermissions['*']['edit'] = false;
|
||||||
|
$wgGroupPermissions['*']['createaccount'] = false;
|
||||||
|
|
||||||
|
# Only admins can create accounts
|
||||||
|
$wgGroupPermissions['sysop']['createaccount'] = true;
|
||||||
BIN
family/__pycache__/migrate.cpython-312.pyc
Normal file
BIN
family/__pycache__/migrate.cpython-312.pyc
Normal file
Binary file not shown.
32
family/docker-compose.yml
Normal file
32
family/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
mediawiki:
|
||||||
|
build: .
|
||||||
|
image: mediawiki-tmh:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8099:80"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/wiki/mediawiki_images:/var/www/html/images
|
||||||
|
- ./LocalSettings.php:/var/www/html/LocalSettings.php # uncomment after initial setup
|
||||||
|
- ./IMG_0448.JPG:/var/www/html/images/logo.jpg
|
||||||
|
- ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
|
||||||
|
environment:
|
||||||
|
MEDIAWIKI_DB_HOST: db
|
||||||
|
MEDIAWIKI_DB_NAME: mediawiki
|
||||||
|
MEDIAWIKI_DB_USER: mw_k7px2q
|
||||||
|
MEDIAWIKI_DB_PASSWORD: Vt9#mLqR4wXn8bZ2
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:lts
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: mediawiki
|
||||||
|
MYSQL_USER: mw_k7px2q
|
||||||
|
MYSQL_PASSWORD: Vt9#mLqR4wXn8bZ2
|
||||||
|
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/wiki/mediawiki_db:/var/lib/mysql
|
||||||
|
|
||||||
|
|
||||||
458
family/migrate.py
Normal file
458
family/migrate.py
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OtterWiki → MediaWiki migration script."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
REPO = Path('/mnt/ssd/dbs/otter/app-data/repository')
|
||||||
|
API = 'http://localhost:8099/api.php'
|
||||||
|
|
||||||
|
_FN_DEF = re.compile(r'^\[(\^[^\]]+)\]:\s*(.*)')
|
||||||
|
|
||||||
|
# Cached pandoc availability (None = not yet checked)
|
||||||
|
_PANDOC_AVAILABLE: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _cap(s: str) -> str:
|
||||||
|
"""Title-case: capitalize first letter of each word."""
|
||||||
|
return s.title() if s else s
|
||||||
|
|
||||||
|
|
||||||
|
def _pandoc_available() -> bool:
|
||||||
|
global _PANDOC_AVAILABLE
|
||||||
|
if _PANDOC_AVAILABLE is None:
|
||||||
|
try:
|
||||||
|
_PANDOC_AVAILABLE = subprocess.run(
|
||||||
|
['pandoc', '--version'], capture_output=True
|
||||||
|
).returncode == 0
|
||||||
|
except FileNotFoundError:
|
||||||
|
_PANDOC_AVAILABLE = False
|
||||||
|
return _PANDOC_AVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MediaWiki session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def mw_login(user: str, password: str):
|
||||||
|
s = requests.Session()
|
||||||
|
r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'type': 'login', 'format': 'json'})
|
||||||
|
token = r.json()['query']['tokens']['logintoken']
|
||||||
|
s.post(API, data={'action': 'login', 'lgname': user, 'lgpassword': password,
|
||||||
|
'lgtoken': token, 'format': 'json'})
|
||||||
|
r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'format': 'json'})
|
||||||
|
csrf = r.json()['query']['tokens']['csrftoken']
|
||||||
|
return s, csrf
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Title determination
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def page_title(md_path: Path) -> str:
|
||||||
|
parts = md_path.relative_to(REPO).parts
|
||||||
|
if md_path.name == 'home.md' and len(parts) == 1:
|
||||||
|
return 'Заглавная страница'
|
||||||
|
return _cap(md_path.stem)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Markdown → wikitext conversion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def convert_pandoc(text: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
['pandoc', '-f', 'markdown', '-t', 'mediawiki'],
|
||||||
|
input=text, capture_output=True, text=True
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def convert_python(text: str, skip_info: bool = False) -> str:
|
||||||
|
lines = text.split('\n')
|
||||||
|
|
||||||
|
# Collect footnote definitions in one pass
|
||||||
|
footnotes: dict[str, str] = {}
|
||||||
|
for line in lines:
|
||||||
|
m = _FN_DEF.match(line)
|
||||||
|
if m:
|
||||||
|
footnotes[m.group(1)] = m.group(2)
|
||||||
|
|
||||||
|
def replace_fn(m):
|
||||||
|
key = m.group(0)[1:-1] # strip outer [ ] to match footnotes dict keys
|
||||||
|
content = footnotes.get(key, m.group(0))
|
||||||
|
content = re.sub(r'\[([^\]^][^\]]*)\]\(([^)]+)\)', r'[\2 \1]', content)
|
||||||
|
return f'<ref>{content}</ref>'
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for line in lines:
|
||||||
|
if _FN_DEF.match(line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = re.match(r'^(#{1,6})\s+(.*)', line)
|
||||||
|
if m:
|
||||||
|
eq = '=' * len(m.group(1))
|
||||||
|
out.append(f'{eq} {m.group(2)} {eq}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if re.match(r'^---+$', line.strip()):
|
||||||
|
out.append('----')
|
||||||
|
continue
|
||||||
|
|
||||||
|
line = re.sub(r'^(\s*)-(\s)', r'\1*\2', line)
|
||||||
|
line = re.sub(r'\*\*\*(.+?)\*\*\*', r"'''''\1'''''", line)
|
||||||
|
line = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", line)
|
||||||
|
line = re.sub(r'\*(.+?)\*', r"''\1''", line)
|
||||||
|
line = re.sub(r'(?<!!)\[([^\]^!][^\]]*)\]\(([^)]+)\)', r'[\2 \1]', line)
|
||||||
|
line = re.sub(r'\[\^\S+?\]', replace_fn, line)
|
||||||
|
out.append(line)
|
||||||
|
|
||||||
|
result = convert_tables('\n'.join(out), skip_info=skip_info)
|
||||||
|
if footnotes:
|
||||||
|
result += '\n<references />'
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _split_cells(line: str) -> list[str]:
|
||||||
|
"""Split a markdown table row on | but not inside [[ ]]."""
|
||||||
|
cells = []
|
||||||
|
depth = 0
|
||||||
|
current = []
|
||||||
|
i = 0
|
||||||
|
# Strip leading/trailing |
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('|'):
|
||||||
|
line = line[1:]
|
||||||
|
if line.endswith('|'):
|
||||||
|
line = line[:-1]
|
||||||
|
while i < len(line):
|
||||||
|
if line[i:i+2] == '[[':
|
||||||
|
depth += 1
|
||||||
|
current.append('[[')
|
||||||
|
i += 2
|
||||||
|
elif line[i:i+2] == ']]':
|
||||||
|
depth -= 1
|
||||||
|
current.append(']]')
|
||||||
|
i += 2
|
||||||
|
elif line[i] == '|' and depth == 0:
|
||||||
|
cells.append(''.join(current).strip())
|
||||||
|
current = []
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
current.append(line[i])
|
||||||
|
i += 1
|
||||||
|
cells.append(''.join(current).strip())
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
def convert_tables(text: str, skip_info: bool = False) -> str:
|
||||||
|
lines = text.split('\n')
|
||||||
|
out = []
|
||||||
|
in_table = False
|
||||||
|
skip_table = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if re.match(r'^\|', line):
|
||||||
|
cells = _split_cells(line)
|
||||||
|
if all(re.match(r'^:?-+:?$', c) for c in cells if c):
|
||||||
|
# Separator row: start or continue table
|
||||||
|
if not in_table:
|
||||||
|
header_line = out.pop() if out else ''
|
||||||
|
hcells = _split_cells(header_line)
|
||||||
|
# Blank-header table (all-empty header cells) = OtterWiki info table
|
||||||
|
if skip_info and all(c == '' for c in hcells):
|
||||||
|
skip_table = True
|
||||||
|
in_table = True
|
||||||
|
else:
|
||||||
|
out += ['{| class="wikitable"', '|-', '! ' + ' !! '.join(hcells)]
|
||||||
|
in_table = True
|
||||||
|
skip_table = False
|
||||||
|
if not skip_table:
|
||||||
|
out.append('|-')
|
||||||
|
else:
|
||||||
|
if in_table:
|
||||||
|
if not skip_table:
|
||||||
|
out.append('|-')
|
||||||
|
out.append('| ' + ' || '.join(cells))
|
||||||
|
# else: skip info table row
|
||||||
|
else:
|
||||||
|
out.append(line)
|
||||||
|
else:
|
||||||
|
if in_table:
|
||||||
|
if not skip_table:
|
||||||
|
out.append('|}')
|
||||||
|
in_table = False
|
||||||
|
skip_table = False
|
||||||
|
out.append(line)
|
||||||
|
|
||||||
|
if in_table and not skip_table:
|
||||||
|
out.append('|}')
|
||||||
|
|
||||||
|
return '\n'.join(out)
|
||||||
|
|
||||||
|
|
||||||
|
_PERSON_FIELD_MAP = {
|
||||||
|
'родился': 'родился', 'родилась': 'родился',
|
||||||
|
'умер': 'умер', 'умерла': 'умер',
|
||||||
|
'отец': 'отец', 'мать': 'мать',
|
||||||
|
'супруг': 'супруг', 'супруга': 'супруг', 'муж': 'супруг', 'жена': 'супруг',
|
||||||
|
'дети': 'дети', 'ребёнок': 'дети',
|
||||||
|
'братья': 'братья', 'брат': 'братья', 'сестра': 'братья',
|
||||||
|
'сёстры': 'братья', 'сестры': 'братья',
|
||||||
|
'место рождения': 'место_рождения', 'место_рождения': 'место_рождения',
|
||||||
|
'прочее': 'прочее',
|
||||||
|
}
|
||||||
|
_PERSONA_PARAM_ORDER = ['родился', 'место_рождения', 'умер', 'отец', 'мать', 'супруг', 'дети', 'братья', 'прочее']
|
||||||
|
|
||||||
|
_PLACE_FIELD_MAP = {
|
||||||
|
'тип': 'тип',
|
||||||
|
'статус': 'статус',
|
||||||
|
'страна': 'страна',
|
||||||
|
'регион': 'регион', 'область': 'регион',
|
||||||
|
'район': 'район', 'расположение': 'район', 'самоуправление': 'район',
|
||||||
|
'река': 'река',
|
||||||
|
'основана': 'основана', 'основан': 'основана',
|
||||||
|
'население': 'население',
|
||||||
|
'адрес': 'адрес',
|
||||||
|
'период': 'период', 'годы': 'период',
|
||||||
|
'жильцы': 'жильцы', 'жильцы/семья': 'жильцы', 'семейное имя': 'прочее',
|
||||||
|
'латв. название': 'назв_латыш',
|
||||||
|
'белор. название': 'назв_белор',
|
||||||
|
'координаты': 'координаты',
|
||||||
|
'сайт': 'сайт',
|
||||||
|
'телефон': 'телефон', 'email': 'телефон',
|
||||||
|
'полное название': 'прочее', 'классы': 'прочее',
|
||||||
|
'штаб-квартира': 'прочее', 'сотрудников': 'прочее',
|
||||||
|
}
|
||||||
|
_PLACE_PARAM_ORDER = ['тип', 'статус', 'страна', 'регион', 'район', 'река', 'основана',
|
||||||
|
'население', 'адрес', 'период', 'жильцы', 'назв_латыш', 'назв_белор',
|
||||||
|
'координаты', 'сайт', 'телефон', 'прочее']
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_infobox(text: str, title: str, name_param: str, template: str,
|
||||||
|
field_map: dict, param_order: list) -> tuple[str, str]:
|
||||||
|
"""Extract photo + blank-header info table; return ({{Template|...}}, cleaned_text)."""
|
||||||
|
lines = text.split('\n')
|
||||||
|
photo = None
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
remove: set[int] = set()
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if re.match(r'^\|', line):
|
||||||
|
break # reached info table — stop looking for photo
|
||||||
|
m = re.match(r'^\s*\[!\[[^\]]*\]\(\./(?:[^/)]+/)?([^)?]+?)(?:\?[^)]*)?\)\]', line)
|
||||||
|
if m:
|
||||||
|
photo = m.group(1)
|
||||||
|
remove.add(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
in_table = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if re.match(r'^\|', line):
|
||||||
|
cells = _split_cells(line)
|
||||||
|
if all(re.match(r'^:?-+:?$', c) for c in cells if c):
|
||||||
|
if not in_table:
|
||||||
|
prev = i - 1
|
||||||
|
if prev >= 0 and re.match(r'^\|', lines[prev]):
|
||||||
|
hcells = _split_cells(lines[prev])
|
||||||
|
if all(c == '' for c in hcells):
|
||||||
|
in_table = True
|
||||||
|
remove.add(prev)
|
||||||
|
if in_table:
|
||||||
|
remove.add(i)
|
||||||
|
elif in_table:
|
||||||
|
remove.add(i)
|
||||||
|
if len(cells) >= 2:
|
||||||
|
key = re.sub(r'\*\*(.+?)\*\*', r'\1', cells[0]).strip().lower()
|
||||||
|
val = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", cells[1].strip())
|
||||||
|
param = field_map.get(key)
|
||||||
|
if param and param not in fields:
|
||||||
|
fields[param] = val
|
||||||
|
elif in_table:
|
||||||
|
in_table = False
|
||||||
|
|
||||||
|
if not photo and not fields:
|
||||||
|
return '', text
|
||||||
|
|
||||||
|
parts = ['{{' + template, f'| {name_param:<16} = {title}']
|
||||||
|
if photo:
|
||||||
|
parts.append(f'| фото = {photo}')
|
||||||
|
for param in param_order:
|
||||||
|
if param in fields:
|
||||||
|
parts.append(f'| {param:<16} = {fields[param]}')
|
||||||
|
infobox = '\n'.join(parts) + '\n}}'
|
||||||
|
cleaned = '\n'.join(line for i, line in enumerate(lines) if i not in remove)
|
||||||
|
return infobox, cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def extract_person_infobox(text: str, title: str) -> tuple[str, str]:
|
||||||
|
return _extract_infobox(text, title, 'имя', 'Персона', _PERSON_FIELD_MAP, _PERSONA_PARAM_ORDER)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_place_infobox(text: str, title: str) -> tuple[str, str]:
|
||||||
|
return _extract_infobox(text, title, 'название', 'Место', _PLACE_FIELD_MAP, _PLACE_PARAM_ORDER)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_first_heading(text: str) -> str:
|
||||||
|
"""Remove the first H1 line — MW displays the page title itself."""
|
||||||
|
return re.sub(r'^#[^#][^\n]*\n?', '', text, count=1)
|
||||||
|
|
||||||
|
|
||||||
|
def convert(text: str, skip_info: bool = False, is_place: bool = False, title: str = '') -> str:
|
||||||
|
text = strip_first_heading(text)
|
||||||
|
infobox = ''
|
||||||
|
if skip_info:
|
||||||
|
infobox, text = extract_person_infobox(text, title)
|
||||||
|
elif is_place:
|
||||||
|
infobox, text = extract_place_infobox(text, title)
|
||||||
|
result = convert_pandoc(text) if _pandoc_available() else convert_python(text, skip_info=skip_info)
|
||||||
|
if infobox:
|
||||||
|
result = infobox + '\n' + result.lstrip('\n')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post-processing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fix_links(text: str) -> str:
|
||||||
|
pattern = (r'\[\[([^\]|]+)\|'
|
||||||
|
r'(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)'
|
||||||
|
r'/([^\]]+)\]\]')
|
||||||
|
|
||||||
|
def replace_link(m):
|
||||||
|
display = m.group(1).strip()
|
||||||
|
page = _cap(m.group(2).strip().lower())
|
||||||
|
if display.lower() == page.lower():
|
||||||
|
return f'[[{page}]]'
|
||||||
|
return f'[[{page}|{display}]]'
|
||||||
|
|
||||||
|
text = re.sub(pattern, replace_link, text)
|
||||||
|
# Also handle bare section paths: [[Section/PageName]] → [[PageName]]
|
||||||
|
text = re.sub(
|
||||||
|
r'\[\[(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)/([^\]|]+)\]\]',
|
||||||
|
lambda m: f'[[{m.group(1).strip().lower().title()}]]',
|
||||||
|
text
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def fix_images(text: str) -> str:
|
||||||
|
# Handle linked images: [](./file.jpg)
|
||||||
|
# and plain images: 
|
||||||
|
pattern = r'(?:\[)?!\[[^\]]*\]\(\./(?:[^/)]+/)?([^)?]+?)(\?[^)]*)?\)(?:\]\([^)]*\))?'
|
||||||
|
|
||||||
|
def replace_img(m):
|
||||||
|
filename = m.group(1)
|
||||||
|
size_m = re.search(r'thumbnail=(\d+)', m.group(2) or '')
|
||||||
|
return f'[[File:{filename}|{size_m.group(1)}px]]' if size_m else f'[[File:{filename}]]'
|
||||||
|
|
||||||
|
return re.sub(pattern, replace_img, text)
|
||||||
|
|
||||||
|
|
||||||
|
_CATEGORY_MAP = {'люди': 'Люди', 'места': 'Места', 'воспоминания': 'Воспоминания'}
|
||||||
|
|
||||||
|
|
||||||
|
def category_suffix(md_path: Path) -> str:
|
||||||
|
parts = md_path.relative_to(REPO).parts
|
||||||
|
if len(parts) == 1:
|
||||||
|
return '' if md_path.name == 'home.md' else '\n\n[[Category:Статьи]]'
|
||||||
|
cat = _CATEGORY_MAP.get(parts[0].lower())
|
||||||
|
return f'\n\n[[Category:{cat}]]' if cat else ''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MW operations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def post_page(session, csrf: str, title: str, text: str, dry_run: bool) -> bool:
|
||||||
|
if dry_run:
|
||||||
|
print(f'[DRY] {title}')
|
||||||
|
return True
|
||||||
|
data = session.post(API, data={
|
||||||
|
'action': 'edit', 'title': title, 'text': text,
|
||||||
|
'token': csrf, 'format': 'json'
|
||||||
|
}).json()
|
||||||
|
if 'error' in data:
|
||||||
|
print(f'[ERR] {title}: {data["error"].get("info", data["error"])}')
|
||||||
|
return False
|
||||||
|
print(f'[OK] {title}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(session, csrf: str, image_path: Path, dry_run: bool) -> bool:
|
||||||
|
basename = image_path.name
|
||||||
|
if dry_run:
|
||||||
|
print(f'[DRY] File:{basename}')
|
||||||
|
return True
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
data = session.post(API, data={
|
||||||
|
'action': 'upload', 'filename': basename,
|
||||||
|
'token': csrf, 'format': 'json', 'ignorewarnings': '1'
|
||||||
|
}, files={'file': f}).json()
|
||||||
|
if 'error' in data:
|
||||||
|
print(f'[ERR] File:{basename}: {data["error"].get("info", data["error"])}')
|
||||||
|
return False
|
||||||
|
if data.get('upload', {}).get('result') == 'Success':
|
||||||
|
print(f'[OK] File:{basename}')
|
||||||
|
else:
|
||||||
|
print(f'[SKIP] File:{basename} (already exists or no change)')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def collect_images() -> list[Path]:
|
||||||
|
images = []
|
||||||
|
for folder in ('люди', 'места'):
|
||||||
|
p = REPO / folder
|
||||||
|
if p.exists():
|
||||||
|
for ext in ('*.jpg', '*.jpeg', '*.JPG', '*.JPEG', '*.png', '*.PNG'):
|
||||||
|
images.extend(p.rglob(ext))
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Migrate OtterWiki to MediaWiki')
|
||||||
|
parser.add_argument('--user', required=True)
|
||||||
|
parser.add_argument('--password', required=True)
|
||||||
|
parser.add_argument('--dry-run', action='store_true')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
session = csrf = None
|
||||||
|
if not args.dry_run:
|
||||||
|
session, csrf = mw_login(args.user, args.password)
|
||||||
|
|
||||||
|
pages_ok = pages_err = images_ok = images_err = 0
|
||||||
|
|
||||||
|
for md_path in sorted(REPO.rglob('*.md')):
|
||||||
|
title = page_title(md_path)
|
||||||
|
raw = md_path.read_text(encoding='utf-8')
|
||||||
|
parts = md_path.relative_to(REPO).parts
|
||||||
|
is_people = len(parts) > 0 and parts[0].lower() == 'люди'
|
||||||
|
is_place = len(parts) > 0 and parts[0].lower() == 'места'
|
||||||
|
wikitext = convert(raw, skip_info=is_people, is_place=is_place, title=title)
|
||||||
|
wikitext = fix_links(wikitext)
|
||||||
|
wikitext = fix_images(wikitext)
|
||||||
|
wikitext += category_suffix(md_path)
|
||||||
|
if post_page(session, csrf, title, wikitext, args.dry_run):
|
||||||
|
pages_ok += 1
|
||||||
|
else:
|
||||||
|
pages_err += 1
|
||||||
|
|
||||||
|
for img in collect_images():
|
||||||
|
if upload_image(session, csrf, img, args.dry_run):
|
||||||
|
images_ok += 1
|
||||||
|
else:
|
||||||
|
images_err += 1
|
||||||
|
|
||||||
|
print(f'\nDone: {pages_ok} pages, {images_ok} images, {pages_err + images_err} errors')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
2
family/uploads.ini
Normal file
2
family/uploads.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
upload_max_filesize = 20M
|
||||||
|
post_max_size = 25M
|
||||||
11
freshrss/.env
Normal file
11
freshrss/.env
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
BASE_URL=https://news.alogins.net
|
||||||
|
ADMIN_EMAIL=allogn@gmail.com
|
||||||
|
ADMIN_PASSWORD=ff221f4d1!
|
||||||
|
ADMIN_API_PASSWORD=sfs32003r2
|
||||||
|
# Published port if running locally
|
||||||
|
PUBLISHED_PORT=8091
|
||||||
|
# Database credentials (not relevant if using default SQLite database)
|
||||||
|
DB_HOST=freshrss-db
|
||||||
|
DB_BASE=freshrss
|
||||||
|
DB_PASSWORD=freshrss1945133
|
||||||
|
DB_USER=freshrss12
|
||||||
36
freshrss/docker-compose.yml
Normal file
36
freshrss/docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
freshrss:
|
||||||
|
image: freshrss/freshrss:latest
|
||||||
|
# # Optional build section if you want to build the image locally:
|
||||||
|
# build:
|
||||||
|
# # Pick #latest (slow releases) or #edge (rolling release) or a specific release like #1.27.1
|
||||||
|
# context: https://github.com/FreshRSS/FreshRSS.git#latest
|
||||||
|
# dockerfile: Docker/Dockerfile-Alpine
|
||||||
|
container_name: freshrss
|
||||||
|
hostname: freshrss
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# If you want to open a port 8080 on the local machine:
|
||||||
|
- "8091:80"
|
||||||
|
logging:
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
volumes:
|
||||||
|
- /mnt/dbs/freshrss/data:/var/www/FreshRSS/data
|
||||||
|
- /mnt/dbs/freshrss/extensions:/var/www/FreshRSS/extensions
|
||||||
|
- /mnt/dbs/freshrss/db:/var/lib/postgresql
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_BASE:-freshrss}
|
||||||
|
POSTGRES_USER: ${DB_USER:-freshrss}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-freshrss}
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
CRON_MIN: '3,33'
|
||||||
|
LISTEN: 0.0.0.0:80
|
||||||
|
# Optional healthcheck section:
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cli/health.php"]
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 60s
|
||||||
|
start_interval: 11s
|
||||||
|
interval: 75s
|
||||||
|
retries: 3
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables
|
# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables
|
||||||
|
|
||||||
# The location where your uploaded files are stored
|
# The location where your uploaded files are stored
|
||||||
UPLOAD_LOCATION=/mnt/media/upload
|
UPLOAD_LOCATION=/mnt/smsg/media/upload
|
||||||
THUMB_LOCATION=/mnt/ssd/media/thumbs
|
THUMB_LOCATION=/mnt/smsg/media/thumbs
|
||||||
ENCODED_VIDEO_LOCATION=/mnt/ssd/media/encoded-video
|
ENCODED_VIDEO_LOCATION=/mnt/smsg/media/encoded-video
|
||||||
|
|
||||||
# The location where your database files are stored. Network shares are not supported for the database
|
# The location where your database files are stored. Network shares are not supported for the database
|
||||||
DB_DATA_LOCATION=/mnt/ssd/media/postgres
|
DB_DATA_LOCATION=/mnt/ssd/media/postgres
|
||||||
|
|||||||
@@ -1,30 +1,42 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BACKUP_DIR=/mnt/backups/media
|
BACKUP_DIR=/mnt/toshiba/backups/media
|
||||||
DB_BACKUP_DIR="$BACKUP_DIR/backups"
|
|
||||||
LOG="$BACKUP_DIR/backup.log"
|
LOG="$BACKUP_DIR/backup.log"
|
||||||
RETAIN_DAYS=14
|
VERBOSE=0
|
||||||
|
|
||||||
mkdir -p "$DB_BACKUP_DIR"
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
echo "[$(date)] Starting Immich backup" >> "$LOG"
|
-v|--verbose) VERBOSE=1 ;;
|
||||||
|
esac
|
||||||
# 1. Database dump (must come before file sync)
|
|
||||||
DUMP_FILE="$DB_BACKUP_DIR/immich-db-$(date +%Y%m%dT%H%M%S).sql.gz"
|
|
||||||
docker exec immich_postgres pg_dump --clean --if-exists \
|
|
||||||
--dbname=immich --username=postgres | gzip > "$DUMP_FILE"
|
|
||||||
echo "[$(date)] DB dump: $DUMP_FILE" >> "$LOG"
|
|
||||||
|
|
||||||
# 2. Rsync critical asset folders (skip thumbs and encoded-video — regeneratable)
|
|
||||||
for DIR in library upload profile; do
|
|
||||||
rsync -a --delete /mnt/media/upload/$DIR/ "$BACKUP_DIR/$DIR/" >> "$LOG" 2>&1
|
|
||||||
echo "[$(date)] Synced $DIR" >> "$LOG"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# 3. Remove old DB dumps
|
log() { echo "[$(date)] $*" >> "$LOG"; }
|
||||||
find "$DB_BACKUP_DIR" -name "immich-db-*.sql.gz" -mtime +$RETAIN_DAYS -delete
|
say() { [[ $VERBOSE -eq 1 ]] && echo "$*" || true; }
|
||||||
echo "[$(date)] Cleaned dumps older than ${RETAIN_DAYS}d" >> "$LOG"
|
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
say ""
|
||||||
|
say "┌─────────────────────────────────────┐"
|
||||||
|
say "│ Immich Backup Starting │"
|
||||||
|
say "└─────────────────────────────────────┘"
|
||||||
|
say ""
|
||||||
|
log "Starting Immich backup"
|
||||||
|
|
||||||
|
# Rsync critical asset folders (skip thumbs and encoded-video — regeneratable)
|
||||||
|
RSYNC_OPTS="-a --ignore-existing"
|
||||||
|
[[ $VERBOSE -eq 1 ]] && RSYNC_OPTS="$RSYNC_OPTS --info=progress2"
|
||||||
|
|
||||||
|
for DIR in library upload profile; do
|
||||||
|
say " Syncing $DIR/ ..."
|
||||||
|
rsync $RSYNC_OPTS /mnt/smsg/media/upload/$DIR/ "$BACKUP_DIR/$DIR/" 2>&1 | \
|
||||||
|
tee -a "$LOG" | { [[ $VERBOSE -eq 0 ]] && cat > /dev/null || cat; }
|
||||||
|
log "Synced $DIR"
|
||||||
|
say ""
|
||||||
|
done
|
||||||
|
|
||||||
touch "$BACKUP_DIR/.last_sync"
|
touch "$BACKUP_DIR/.last_sync"
|
||||||
echo "[$(date)] Immich backup complete" >> "$LOG"
|
log "Immich backup complete"
|
||||||
|
say "✓ Done"
|
||||||
|
say ""
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
- redis
|
- redis
|
||||||
- database
|
- database
|
||||||
restart: always
|
restart: always
|
||||||
|
mem_limit: 1500m
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: false
|
disable: false
|
||||||
|
|
||||||
@@ -37,15 +38,16 @@ services:
|
|||||||
container_name: immich_machine_learning
|
container_name: immich_machine_learning
|
||||||
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
|
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
|
||||||
# Example tag: ${IMMICH_VERSION:-release}-cuda
|
# Example tag: ${IMMICH_VERSION:-release}-cuda
|
||||||
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
|
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-cuda
|
||||||
# extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
|
extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
|
||||||
# file: hwaccel.ml.yml
|
file: hwaccel.ml.yml
|
||||||
# service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
|
service: cuda # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
|
||||||
volumes:
|
volumes:
|
||||||
- model-cache:/cache
|
- model-cache:/cache
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: always
|
restart: always
|
||||||
|
mem_limit: 750m
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: false
|
disable: false
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ services:
|
|||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
mem_limit: 256m
|
||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
@@ -71,6 +74,7 @@ services:
|
|||||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||||
shm_size: 128mb
|
shm_size: 128mb
|
||||||
restart: always
|
restart: always
|
||||||
|
mem_limit: 1500m
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model-cache:
|
||||||
|
|||||||
57
immich-app/hwaccel.ml.yml
Normal file
57
immich-app/hwaccel.ml.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Configurations for hardware-accelerated machine learning
|
||||||
|
|
||||||
|
# If using Unraid or another platform that doesn't allow multiple Compose files,
|
||||||
|
# you can inline the config for a backend by copying its contents
|
||||||
|
# into the immich-machine-learning service in the docker-compose.yml file.
|
||||||
|
|
||||||
|
# See https://docs.immich.app/features/ml-hardware-acceleration for info on usage.
|
||||||
|
|
||||||
|
services:
|
||||||
|
armnn:
|
||||||
|
devices:
|
||||||
|
- /dev/mali0:/dev/mali0
|
||||||
|
volumes:
|
||||||
|
- /lib/firmware/mali_csffw.bin:/lib/firmware/mali_csffw.bin:ro # Mali firmware for your chipset (not always required depending on the driver)
|
||||||
|
- /usr/lib/libmali.so:/usr/lib/libmali.so:ro # Mali driver for your chipset (always required)
|
||||||
|
|
||||||
|
rknn:
|
||||||
|
security_opt:
|
||||||
|
- systempaths=unconfined
|
||||||
|
- apparmor=unconfined
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
|
||||||
|
cpu: {}
|
||||||
|
|
||||||
|
cuda:
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities:
|
||||||
|
- gpu
|
||||||
|
|
||||||
|
rocm:
|
||||||
|
group_add:
|
||||||
|
- video
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
- /dev/kfd:/dev/kfd
|
||||||
|
|
||||||
|
openvino:
|
||||||
|
device_cgroup_rules:
|
||||||
|
- 'c 189:* rmw'
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
volumes:
|
||||||
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
|
|
||||||
|
openvino-wsl:
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
- /dev/dxg:/dev/dxg
|
||||||
|
volumes:
|
||||||
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
|
- /usr/lib/wsl:/usr/lib/wsl
|
||||||
BIN
iperf3/CellularLab-v2.2.apk
Normal file
BIN
iperf3/CellularLab-v2.2.apk
Normal file
Binary file not shown.
BIN
iperf3/apk/CellularLab-v2.2.apk
Normal file
BIN
iperf3/apk/CellularLab-v2.2.apk
Normal file
Binary file not shown.
9
iperf3/apk/index.html
Normal file
9
iperf3/apk/index.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>iperf3 Android</title></head>
|
||||||
|
<body>
|
||||||
|
<h2>iperf3 for Android</h2>
|
||||||
|
<p><a href="CellularLab-v2.2.apk">Download CellularLab v2.2 (iperf3 client)</a></p>
|
||||||
|
<p>Server: <code>iperf.alogins.net</code> — port 5201</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
iperf3/docker-compose.yml
Normal file
18
iperf3/docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
iperf3:
|
||||||
|
image: networkstatic/iperf3
|
||||||
|
container_name: iperf3
|
||||||
|
command: -s
|
||||||
|
ports:
|
||||||
|
- "5201:5201"
|
||||||
|
- "5201:5201/udp"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
iperf3-files:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: iperf3-files
|
||||||
|
ports:
|
||||||
|
- "8095:80"
|
||||||
|
volumes:
|
||||||
|
- ./apk:/usr/share/nginx/html:ro
|
||||||
|
restart: unless-stopped
|
||||||
17
kanboard/docker-compose.yml
Normal file
17
kanboard/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: kanboard
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: kanboard/kanboard:latest
|
||||||
|
container_name: kanboard
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:4800:80"
|
||||||
|
volumes:
|
||||||
|
- data:/var/www/app/data
|
||||||
|
- plugins:/var/www/app/plugins
|
||||||
|
environment:
|
||||||
|
- PLUGIN_INSTALLER=true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
plugins:
|
||||||
473
linkwarden/.env
Normal file
473
linkwarden/.env
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
NEXTAUTH_URL=https://lw.alogins.net/api/v1/auth
|
||||||
|
NEXTAUTH_SECRET=sdf2323frghjkj211
|
||||||
|
|
||||||
|
# Manual installation database settings
|
||||||
|
# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
# Docker installation database settings
|
||||||
|
POSTGRES_PASSWORD=KAf122!!fsdf2w
|
||||||
|
|
||||||
|
# Additional Optional Settings
|
||||||
|
PAGINATION_TAKE_COUNT=
|
||||||
|
STORAGE_FOLDER=
|
||||||
|
AUTOSCROLL_TIMEOUT=
|
||||||
|
NEXT_PUBLIC_DISABLE_REGISTRATION=true
|
||||||
|
NEXT_PUBLIC_CREDENTIALS_ENABLED=
|
||||||
|
DISABLE_NEW_SSO_USERS=
|
||||||
|
MAX_LINKS_PER_USER=
|
||||||
|
ARCHIVE_TAKE_COUNT=
|
||||||
|
BROWSER_TIMEOUT=
|
||||||
|
IGNORE_URL_SIZE_LIMIT=
|
||||||
|
NEXT_PUBLIC_DEMO=
|
||||||
|
NEXT_PUBLIC_DEMO_USERNAME=
|
||||||
|
NEXT_PUBLIC_DEMO_PASSWORD=
|
||||||
|
NEXT_PUBLIC_ADMIN=
|
||||||
|
NEXT_PUBLIC_MAX_FILE_BUFFER=
|
||||||
|
PDF_MAX_BUFFER=
|
||||||
|
SCREENSHOT_MAX_BUFFER=
|
||||||
|
READABILITY_MAX_BUFFER=
|
||||||
|
PREVIEW_MAX_BUFFER=
|
||||||
|
MONOLITH_MAX_BUFFER=
|
||||||
|
MONOLITH_CUSTOM_OPTIONS=
|
||||||
|
IMPORT_LIMIT=
|
||||||
|
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH=
|
||||||
|
PLAYWRIGHT_WS_URL=
|
||||||
|
MAX_WORKERS=
|
||||||
|
DISABLE_PRESERVATION=
|
||||||
|
NEXT_PUBLIC_RSS_POLLING_INTERVAL_MINUTES=
|
||||||
|
RSS_SUBSCRIPTION_LIMIT_PER_USER=
|
||||||
|
TEXT_CONTENT_LIMIT=
|
||||||
|
SEARCH_FILTER_LIMIT=
|
||||||
|
INDEX_TAKE_COUNT=
|
||||||
|
MEILI_TIMEOUT=
|
||||||
|
ALLOW_PRIVATE_NETWORK_ACCESS=
|
||||||
|
ALLOW_INSECURE_TLS=
|
||||||
|
|
||||||
|
# AI Settings
|
||||||
|
NEXT_PUBLIC_OLLAMA_ENDPOINT_URL=
|
||||||
|
OLLAMA_MODEL=
|
||||||
|
|
||||||
|
# https://ai-sdk.dev/providers/openai-compatible-providers
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_MODEL=
|
||||||
|
# Optional: Set a custom OpenAI base URL and name (for third-party providers)
|
||||||
|
CUSTOM_OPENAI_BASE_URL=
|
||||||
|
CUSTOM_OPENAI_NAME=
|
||||||
|
|
||||||
|
# https://sdk.vercel.ai/providers/ai-sdk-providers/azure
|
||||||
|
AZURE_API_KEY=
|
||||||
|
AZURE_RESOURCE_NAME=
|
||||||
|
AZURE_MODEL=
|
||||||
|
|
||||||
|
# https://sdk.vercel.ai/providers/ai-sdk-providers/anthropic
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
ANTHROPIC_MODEL=
|
||||||
|
|
||||||
|
# https://github.com/OpenRouterTeam/ai-sdk-provider
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
OPENROUTER_MODEL=
|
||||||
|
|
||||||
|
# https://ai-sdk.dev/providers/ai-sdk-providers/perplexity
|
||||||
|
PERPLEXITY_API_KEY=
|
||||||
|
PERPLEXITY_MODEL=
|
||||||
|
|
||||||
|
# MeiliSearch Settings
|
||||||
|
MEILI_HOST=
|
||||||
|
MEILI_MASTER_KEY=FAfg24!@bbqq
|
||||||
|
|
||||||
|
# AWS S3 Settings
|
||||||
|
SPACES_KEY=
|
||||||
|
SPACES_SECRET=
|
||||||
|
SPACES_ENDPOINT=
|
||||||
|
SPACES_BUCKET_NAME=
|
||||||
|
SPACES_REGION=
|
||||||
|
SPACES_FORCE_PATH_STYLE=
|
||||||
|
|
||||||
|
# SMTP Settings
|
||||||
|
NEXT_PUBLIC_EMAIL_PROVIDER=
|
||||||
|
EMAIL_FROM=
|
||||||
|
EMAIL_SERVER=
|
||||||
|
BASE_URL=
|
||||||
|
|
||||||
|
# Proxy settings
|
||||||
|
PROXY=
|
||||||
|
PROXY_USERNAME=
|
||||||
|
PROXY_PASSWORD=
|
||||||
|
PROXY_BYPASS=
|
||||||
|
|
||||||
|
# PDF archive settings
|
||||||
|
PDF_MARGIN_TOP=
|
||||||
|
PDF_MARGIN_BOTTOM=
|
||||||
|
|
||||||
|
#################
|
||||||
|
# SSO Providers #
|
||||||
|
#################
|
||||||
|
|
||||||
|
# 42 School
|
||||||
|
NEXT_PUBLIC_FORTYTWO_ENABLED=
|
||||||
|
FORTYTWO_CUSTOM_NAME=
|
||||||
|
FORTYTWO_CLIENT_ID=
|
||||||
|
FORTYTWO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Apple
|
||||||
|
NEXT_PUBLIC_APPLE_ENABLED=
|
||||||
|
APPLE_CUSTOM_NAME=
|
||||||
|
APPLE_ID=
|
||||||
|
APPLE_SECRET=
|
||||||
|
|
||||||
|
# Atlassian
|
||||||
|
NEXT_PUBLIC_ATLASSIAN_ENABLED=
|
||||||
|
ATLASSIAN_CUSTOM_NAME=
|
||||||
|
ATLASSIAN_CLIENT_ID=
|
||||||
|
ATLASSIAN_CLIENT_SECRET=
|
||||||
|
ATLASSIAN_SCOPE=
|
||||||
|
|
||||||
|
# Auth0
|
||||||
|
NEXT_PUBLIC_AUTH0_ENABLED=
|
||||||
|
AUTH0_CUSTOM_NAME=
|
||||||
|
AUTH0_ISSUER=
|
||||||
|
AUTH0_CLIENT_SECRET=
|
||||||
|
AUTH0_CLIENT_ID=
|
||||||
|
|
||||||
|
# Authelia
|
||||||
|
NEXT_PUBLIC_AUTHELIA_ENABLED=
|
||||||
|
AUTHELIA_CLIENT_ID=
|
||||||
|
AUTHELIA_CLIENT_SECRET=
|
||||||
|
AUTHELIA_WELLKNOWN_URL=
|
||||||
|
|
||||||
|
# Authentik
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||||
|
AUTHENTIK_CUSTOM_NAME=
|
||||||
|
AUTHENTIK_ISSUER=
|
||||||
|
AUTHENTIK_CLIENT_ID=
|
||||||
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Azure AD B2C
|
||||||
|
NEXT_PUBLIC_AZURE_AD_B2C_ENABLED=
|
||||||
|
AZURE_AD_B2C_TENANT_NAME=
|
||||||
|
AZURE_AD_B2C_CLIENT_ID=
|
||||||
|
AZURE_AD_B2C_CLIENT_SECRET=
|
||||||
|
AZURE_AD_B2C_PRIMARY_USER_FLOW=
|
||||||
|
|
||||||
|
# Azure AD
|
||||||
|
NEXT_PUBLIC_AZURE_AD_ENABLED=
|
||||||
|
AZURE_AD_CLIENT_ID=
|
||||||
|
AZURE_AD_CLIENT_SECRET=
|
||||||
|
AZURE_AD_TENANT_ID=
|
||||||
|
|
||||||
|
# Battle.net
|
||||||
|
NEXT_PUBLIC_BATTLENET_ENABLED=
|
||||||
|
BATTLENET_CUSTOM_NAME=
|
||||||
|
BATTLENET_CLIENT_ID=
|
||||||
|
BATTLENET_CLIENT_SECRET=
|
||||||
|
BATTLENET_ISSUER=
|
||||||
|
|
||||||
|
# Box
|
||||||
|
NEXT_PUBLIC_BOX_ENABLED=
|
||||||
|
BOX_CUSTOM_NAME=
|
||||||
|
BOX_CLIENT_ID=
|
||||||
|
BOX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Bungie
|
||||||
|
NEXT_PUBLIC_BUNGIE_ENABLED=
|
||||||
|
BUNGIE_CUSTOM_NAME=
|
||||||
|
BUNGIE_CLIENT_ID=
|
||||||
|
BUNGIE_CLIENT_SECRET=
|
||||||
|
BUNGIE_API_KEY=
|
||||||
|
|
||||||
|
# Cognito
|
||||||
|
NEXT_PUBLIC_COGNITO_ENABLED=
|
||||||
|
COGNITO_CUSTOM_NAME=
|
||||||
|
COGNITO_CLIENT_ID=
|
||||||
|
COGNITO_CLIENT_SECRET=
|
||||||
|
COGNITO_ISSUER=
|
||||||
|
|
||||||
|
# Coinbase
|
||||||
|
NEXT_PUBLIC_COINBASE_ENABLED=
|
||||||
|
COINBASE_CUSTOM_NAME=
|
||||||
|
COINBASE_CLIENT_ID=
|
||||||
|
COINBASE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
NEXT_PUBLIC_DISCORD_ENABLED=
|
||||||
|
DISCORD_CUSTOM_NAME=
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Dropbox
|
||||||
|
NEXT_PUBLIC_DROPBOX_ENABLED=
|
||||||
|
DROPBOX_CUSTOM_NAME=
|
||||||
|
DROPBOX_CLIENT_ID=
|
||||||
|
DROPBOX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# DuendeIndentityServer6
|
||||||
|
NEXT_PUBLIC_DUENDE_IDS6_ENABLED=
|
||||||
|
DUENDE_IDS6_CUSTOM_NAME=
|
||||||
|
DUENDE_IDS6_CLIENT_ID=
|
||||||
|
DUENDE_IDS6_CLIENT_SECRET=
|
||||||
|
DUENDE_IDS6_ISSUER=
|
||||||
|
|
||||||
|
# EVE Online
|
||||||
|
NEXT_PUBLIC_EVEONLINE_ENABLED=
|
||||||
|
EVEONLINE_CUSTOM_NAME=
|
||||||
|
EVEONLINE_CLIENT_ID=
|
||||||
|
EVEONLINE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Facebook
|
||||||
|
NEXT_PUBLIC_FACEBOOK_ENABLED=
|
||||||
|
FACEBOOK_CUSTOM_NAME=
|
||||||
|
FACEBOOK_CLIENT_ID=
|
||||||
|
FACEBOOK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# FACEIT
|
||||||
|
NEXT_PUBLIC_FACEIT_ENABLED=
|
||||||
|
FACEIT_CUSTOM_NAME=
|
||||||
|
FACEIT_CLIENT_ID=
|
||||||
|
FACEIT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Foursquare
|
||||||
|
NEXT_PUBLIC_FOURSQUARE_ENABLED=
|
||||||
|
FOURSQUARE_CUSTOM_NAME=
|
||||||
|
FOURSQUARE_CLIENT_ID=
|
||||||
|
FOURSQUARE_CLIENT_SECRET=
|
||||||
|
FOURSQUARE_APIVERSION=
|
||||||
|
|
||||||
|
# Freshbooks
|
||||||
|
NEXT_PUBLIC_FRESHBOOKS_ENABLED=
|
||||||
|
FRESHBOOKS_CUSTOM_NAME=
|
||||||
|
FRESHBOOKS_CLIENT_ID=
|
||||||
|
FRESHBOOKS_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# FusionAuth
|
||||||
|
NEXT_PUBLIC_FUSIONAUTH_ENABLED=
|
||||||
|
FUSIONAUTH_CUSTOM_NAME=
|
||||||
|
FUSIONAUTH_CLIENT_ID=
|
||||||
|
FUSIONAUTH_CLIENT_SECRET=
|
||||||
|
FUSIONAUTH_ISSUER=
|
||||||
|
FUSIONAUTH_TENANT_ID=
|
||||||
|
|
||||||
|
# GitHub
|
||||||
|
NEXT_PUBLIC_GITHUB_ENABLED=
|
||||||
|
GITHUB_CUSTOM_NAME=
|
||||||
|
GITHUB_ID=
|
||||||
|
GITHUB_SECRET=
|
||||||
|
|
||||||
|
# GitLab
|
||||||
|
NEXT_PUBLIC_GITLAB_ENABLED=
|
||||||
|
GITLAB_CUSTOM_NAME=
|
||||||
|
GITLAB_CLIENT_ID=
|
||||||
|
GITLAB_CLIENT_SECRET=
|
||||||
|
GITLAB_AUTH_URL=
|
||||||
|
|
||||||
|
# Google
|
||||||
|
NEXT_PUBLIC_GOOGLE_ENABLED=
|
||||||
|
GOOGLE_CUSTOM_NAME=
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# HubSpot
|
||||||
|
NEXT_PUBLIC_HUBSPOT_ENABLED=
|
||||||
|
HUBSPOT_CUSTOM_NAME=
|
||||||
|
HUBSPOT_CLIENT_ID=
|
||||||
|
HUBSPOT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# IdentityServer4
|
||||||
|
NEXT_PUBLIC_IDS4_ENABLED=
|
||||||
|
IDS4_CUSTOM_NAME=
|
||||||
|
IDS4_CLIENT_ID=
|
||||||
|
IDS4_CLIENT_SECRET=
|
||||||
|
IDS4_ISSUER=
|
||||||
|
|
||||||
|
# Kakao
|
||||||
|
NEXT_PUBLIC_KAKAO_ENABLED=
|
||||||
|
KAKAO_CUSTOM_NAME=
|
||||||
|
KAKAO_CLIENT_ID=
|
||||||
|
KAKAO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Keycloak
|
||||||
|
NEXT_PUBLIC_KEYCLOAK_ENABLED=
|
||||||
|
KEYCLOAK_CUSTOM_NAME=
|
||||||
|
KEYCLOAK_ISSUER=
|
||||||
|
KEYCLOAK_CLIENT_ID=
|
||||||
|
KEYCLOAK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# LINE
|
||||||
|
NEXT_PUBLIC_LINE_ENABLED=
|
||||||
|
LINE_CUSTOM_NAME=
|
||||||
|
LINE_CLIENT_ID=
|
||||||
|
LINE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# LinkedIn
|
||||||
|
NEXT_PUBLIC_LINKEDIN_ENABLED=
|
||||||
|
LINKEDIN_CUSTOM_NAME=
|
||||||
|
LINKEDIN_CLIENT_ID=
|
||||||
|
LINKEDIN_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Mailchimp
|
||||||
|
NEXT_PUBLIC_MAILCHIMP_ENABLED=
|
||||||
|
MAILCHIMP_CUSTOM_NAME=
|
||||||
|
MAILCHIMP_CLIENT_ID=
|
||||||
|
MAILCHIMP_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Mail.ru
|
||||||
|
NEXT_PUBLIC_MAILRU_ENABLED=
|
||||||
|
MAILRU_CUSTOM_NAME=
|
||||||
|
MAILRU_CLIENT_ID=
|
||||||
|
MAILRU_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Naver
|
||||||
|
NEXT_PUBLIC_NAVER_ENABLED=
|
||||||
|
NAVER_CUSTOM_NAME=
|
||||||
|
NAVER_CLIENT_ID=
|
||||||
|
NAVER_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Netlify
|
||||||
|
NEXT_PUBLIC_NETLIFY_ENABLED=
|
||||||
|
NETLIFY_CUSTOM_NAME=
|
||||||
|
NETLIFY_CLIENT_ID=
|
||||||
|
NETLIFY_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Okta
|
||||||
|
NEXT_PUBLIC_OKTA_ENABLED=
|
||||||
|
OKTA_CUSTOM_NAME=
|
||||||
|
OKTA_CLIENT_ID=
|
||||||
|
OKTA_CLIENT_SECRET=
|
||||||
|
OKTA_ISSUER=
|
||||||
|
|
||||||
|
# OneLogin
|
||||||
|
NEXT_PUBLIC_ONELOGIN_ENABLED=
|
||||||
|
ONELOGIN_CUSTOM_NAME=
|
||||||
|
ONELOGIN_CLIENT_ID=
|
||||||
|
ONELOGIN_CLIENT_SECRET=
|
||||||
|
ONELOGIN_ISSUER=
|
||||||
|
|
||||||
|
# Osso
|
||||||
|
NEXT_PUBLIC_OSSO_ENABLED=
|
||||||
|
OSSO_CUSTOM_NAME=
|
||||||
|
OSSO_CLIENT_ID=
|
||||||
|
OSSO_CLIENT_SECRET=
|
||||||
|
OSSO_ISSUER=
|
||||||
|
|
||||||
|
# osu!
|
||||||
|
NEXT_PUBLIC_OSU_ENABLED=
|
||||||
|
OSU_CUSTOM_NAME=
|
||||||
|
OSU_CLIENT_ID=
|
||||||
|
OSU_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Patreon
|
||||||
|
NEXT_PUBLIC_PATREON_ENABLED=
|
||||||
|
PATREON_CUSTOM_NAME=
|
||||||
|
PATREON_CLIENT_ID=
|
||||||
|
PATREON_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Pinterest
|
||||||
|
NEXT_PUBLIC_PINTEREST_ENABLED=
|
||||||
|
PINTEREST_CUSTOM_NAME=
|
||||||
|
PINTEREST_CLIENT_ID=
|
||||||
|
PINTEREST_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Pipedrive
|
||||||
|
NEXT_PUBLIC_PIPEDRIVE_ENABLED=
|
||||||
|
PIPEDRIVE_CUSTOM_NAME=
|
||||||
|
PIPEDRIVE_CLIENT_ID=
|
||||||
|
PIPEDRIVE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Reddit
|
||||||
|
NEXT_PUBLIC_REDDIT_ENABLED=
|
||||||
|
REDDIT_CUSTOM_NAME=
|
||||||
|
REDDIT_CLIENT_ID=
|
||||||
|
REDDIT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Salesforce
|
||||||
|
NEXT_PUBLIC_SALESFORCE_ENABLED=
|
||||||
|
SALESFORCE_CUSTOM_NAME=
|
||||||
|
SALESFORCE_CLIENT_ID=
|
||||||
|
SALESFORCE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Slack
|
||||||
|
NEXT_PUBLIC_SLACK_ENABLED=
|
||||||
|
SLACK_CUSTOM_NAME=
|
||||||
|
SLACK_CLIENT_ID=
|
||||||
|
SLACK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Spotify
|
||||||
|
NEXT_PUBLIC_SPOTIFY_ENABLED=
|
||||||
|
SPOTIFY_CUSTOM_NAME=
|
||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Strava
|
||||||
|
NEXT_PUBLIC_STRAVA_ENABLED=
|
||||||
|
STRAVA_CUSTOM_NAME=
|
||||||
|
STRAVA_CLIENT_ID=
|
||||||
|
STRAVA_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Synology
|
||||||
|
NEXT_PUBLIC_SYNOLOGY_ENABLED=
|
||||||
|
SYNOLOGY_CUSTOM_NAME=
|
||||||
|
SYNOLOGY_CLIENT_ID=
|
||||||
|
SYNOLOGY_CLIENT_SECRET=
|
||||||
|
SYNOLOGY_WELLKNOWN_URL=
|
||||||
|
|
||||||
|
# Todoist
|
||||||
|
NEXT_PUBLIC_TODOIST_ENABLED=
|
||||||
|
TODOIST_CUSTOM_NAME=
|
||||||
|
TODOIST_CLIENT_ID=
|
||||||
|
TODOIST_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Twitch
|
||||||
|
NEXT_PUBLIC_TWITCH_ENABLED=
|
||||||
|
TWITCH_CUSTOM_NAME=
|
||||||
|
TWITCH_CLIENT_ID=
|
||||||
|
TWITCH_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# United Effects
|
||||||
|
NEXT_PUBLIC_UNITED_EFFECTS_ENABLED=
|
||||||
|
UNITED_EFFECTS_CUSTOM_NAME=
|
||||||
|
UNITED_EFFECTS_CLIENT_ID=
|
||||||
|
UNITED_EFFECTS_CLIENT_SECRET=
|
||||||
|
UNITED_EFFECTS_ISSUER=
|
||||||
|
|
||||||
|
# VK
|
||||||
|
NEXT_PUBLIC_VK_ENABLED=
|
||||||
|
VK_CUSTOM_NAME=
|
||||||
|
VK_CLIENT_ID=
|
||||||
|
VK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Wikimedia
|
||||||
|
NEXT_PUBLIC_WIKIMEDIA_ENABLED=
|
||||||
|
WIKIMEDIA_CUSTOM_NAME=
|
||||||
|
WIKIMEDIA_CLIENT_ID=
|
||||||
|
WIKIMEDIA_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Wordpress.com
|
||||||
|
NEXT_PUBLIC_WORDPRESS_ENABLED=
|
||||||
|
WORDPRESS_CUSTOM_NAME=
|
||||||
|
WORDPRESS_CLIENT_ID=
|
||||||
|
WORDPRESS_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Yandex
|
||||||
|
NEXT_PUBLIC_YANDEX_ENABLED=
|
||||||
|
YANDEX_CUSTOM_NAME=
|
||||||
|
YANDEX_CLIENT_ID=
|
||||||
|
YANDEX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Zitadel
|
||||||
|
NEXT_PUBLIC_ZITADEL_ENABLED=
|
||||||
|
ZITADEL_CUSTOM_NAME=
|
||||||
|
ZITADEL_CLIENT_ID=
|
||||||
|
ZITADEL_CLIENT_SECRET=
|
||||||
|
ZITADEL_ISSUER=
|
||||||
|
|
||||||
|
# Zoho
|
||||||
|
NEXT_PUBLIC_ZOHO_ENABLED=
|
||||||
|
ZOHO_CUSTOM_NAME=
|
||||||
|
ZOHO_CLIENT_ID=
|
||||||
|
ZOHO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Zoom
|
||||||
|
NEXT_PUBLIC_ZOOM_ENABLED=
|
||||||
|
ZOOM_CUSTOM_NAME=
|
||||||
|
ZOOM_CLIENT_ID=
|
||||||
|
ZOOM_CLIENT_SECRET=
|
||||||
3
linkwarden/.gitignore
vendored
Normal file
3
linkwarden/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
data/
|
||||||
|
meili_data/
|
||||||
|
pgdata/
|
||||||
28
linkwarden/docker-compose.yml
Normal file
28
linkwarden/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env_file: .env
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/linkwarden/pgdata:/var/lib/postgresql/data
|
||||||
|
linkwarden:
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
|
||||||
|
restart: always
|
||||||
|
# build: . # uncomment to build from source
|
||||||
|
image: ghcr.io/linkwarden/linkwarden:latest # comment to build from source
|
||||||
|
ports:
|
||||||
|
- 3012:3000
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/linkwarden/data:/data/data
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- meilisearch
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.12.8
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/linkwarden/meili_data:/meili_data
|
||||||
@@ -26,6 +26,7 @@ Connect clients to: `https://mtx.alogins.net`
|
|||||||
| admin | yes |
|
| admin | yes |
|
||||||
| elizaveta | no |
|
| elizaveta | no |
|
||||||
| aleksandra | no |
|
| aleksandra | no |
|
||||||
|
| juris | no |
|
||||||
|
|
||||||
## Managing Users
|
## Managing Users
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,3 @@ services:
|
|||||||
- OLLAMA_NUM_GPU=999
|
- OLLAMA_NUM_GPU=999
|
||||||
runtime: nvidia
|
runtime: nvidia
|
||||||
mem_limit: 4g
|
mem_limit: 4g
|
||||||
|
|
||||||
ollama-cpu:
|
|
||||||
image: ollama/ollama
|
|
||||||
container_name: ollama-cpu
|
|
||||||
ports:
|
|
||||||
- "11435:11434"
|
|
||||||
volumes:
|
|
||||||
- /mnt/ssd/ai/ollama-cpu:/root/.ollama
|
|
||||||
restart: always
|
|
||||||
|
|||||||
2
openai/.env
Normal file
2
openai/.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
LANGFUSE_PUBLIC_KEY=pk-lf-9a00d546-2fbd-4215-9b0e-0a54362e884b
|
||||||
|
LANGFUSE_SECRET_KEY=sk-lf-b91caabb-7544-4012-830e-2b9e409609d3
|
||||||
@@ -32,3 +32,4 @@ services:
|
|||||||
- AUDIO_TTS_OPENAI_API_KEY=dummy
|
- AUDIO_TTS_OPENAI_API_KEY=dummy
|
||||||
- AUDIO_TTS_MODEL=silero
|
- AUDIO_TTS_MODEL=silero
|
||||||
- AUDIO_TTS_VOICE=onyx
|
- AUDIO_TTS_VOICE=onyx
|
||||||
|
- ENABLE_API_KEYS=True
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
|
|
||||||
networks:
|
|
||||||
macvlan-br0:
|
|
||||||
driver: macvlan
|
|
||||||
driver_opts:
|
|
||||||
parent: br0
|
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 192.168.1.0/24
|
|
||||||
gateway: 192.168.1.1
|
|
||||||
# ip_range: 192.168.1.192/27
|
|
||||||
|
|
||||||
services:
|
|
||||||
pihole:
|
|
||||||
container_name: pihole
|
|
||||||
image: pihole/pihole:latest
|
|
||||||
#ports:
|
|
||||||
# DNS Ports
|
|
||||||
#- "53:53/tcp"
|
|
||||||
#- "53:53/udp"
|
|
||||||
# Default HTTP Port
|
|
||||||
#- "80:80/tcp"
|
|
||||||
# Default HTTPs Port. FTL will generate a self-signed certificate
|
|
||||||
#- "443:443/tcp"
|
|
||||||
# Uncomment the below if using Pi-hole as your DHCP Server
|
|
||||||
#- "67:67/udp"
|
|
||||||
# Uncomment the line below if you are using Pi-hole as your NTP server
|
|
||||||
#- "123:123/udp"
|
|
||||||
|
|
||||||
dns:
|
|
||||||
- 8.8.8.8
|
|
||||||
- 1.1.1.1
|
|
||||||
networks:
|
|
||||||
macvlan-br0:
|
|
||||||
ipv4_address: 192.168.1.2
|
|
||||||
environment:
|
|
||||||
# Set the appropriate timezone for your location from
|
|
||||||
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones, e.g:
|
|
||||||
TZ: 'Europe/Moscow'
|
|
||||||
# Set a password to access the web interface. Not setting one will result in a random password being assigned
|
|
||||||
FTLCONF_webserver_api_password: 'correct horse 123'
|
|
||||||
# If using Docker's default `bridge` network setting the dns listening mode should be set to 'ALL'
|
|
||||||
FTLCONF_dns_listeningMode: 'ALL'
|
|
||||||
# Volumes store your data between container upgrades
|
|
||||||
volumes:
|
|
||||||
# For persisting Pi-hole's databases and common configuration file
|
|
||||||
- '/mnt/ssd/dbs/pihole:/etc/pihole'
|
|
||||||
# Uncomment the below if you have custom dnsmasq config files that you want to persist. Not needed for most starting fresh with Pi-hole v6. If you're upgrading from v5 you and have used this directory before, you should keep it enabled for the first v6 container start to allow for a complete migration. It can be removed afterwards. Needs environment variable FTLCONF_misc_etc_dnsmasq_d: 'true'
|
|
||||||
#- './etc-dnsmasq.d:/etc/dnsmasq.d'
|
|
||||||
cap_add:
|
|
||||||
# See https://github.com/pi-hole/docker-pi-hole#note-on-capabilities
|
|
||||||
# Required if you are using Pi-hole as your DHCP server, else not needed
|
|
||||||
- NET_ADMIN
|
|
||||||
# Required if you are using Pi-hole as your NTP client to be able to set the host's system time
|
|
||||||
- SYS_TIME
|
|
||||||
# Optional, if Pi-hole should get some more processing time
|
|
||||||
- SYS_NICE
|
|
||||||
restart: unless-stopped
|
|
||||||
17
qbittorrent/docker-compose.yml
Normal file
17
qbittorrent/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
qbittorrent:
|
||||||
|
image: lscr.io/linuxserver/qbittorrent:latest
|
||||||
|
container_name: qbittorrent
|
||||||
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
- TZ=Europe/Moscow
|
||||||
|
- WEBUI_PORT=8085
|
||||||
|
volumes:
|
||||||
|
- /mnt/misc/qbittorrent/config:/config
|
||||||
|
- /mnt/misc/qbittorrent/downloads:/downloads
|
||||||
|
ports:
|
||||||
|
- "8085:8085"
|
||||||
|
- "6881:6881"
|
||||||
|
- "6881:6881/udp"
|
||||||
|
restart: unless-stopped
|
||||||
13
radicale/config
Normal file
13
radicale/config
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[server]
|
||||||
|
hosts = 0.0.0.0:5232
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
type = htpasswd
|
||||||
|
htpasswd_filename = /config/users
|
||||||
|
htpasswd_encryption = bcrypt
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
filesystem_folder = /data/collections
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
level = info
|
||||||
9
radicale/docker-compose.yml
Normal file
9
radicale/docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
radicale:
|
||||||
|
image: tomsquest/docker-radicale
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 5232:5232
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/radicale/data:/data
|
||||||
|
- /mnt/ssd/dbs/radicale/config:/config
|
||||||
@@ -95,6 +95,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
extra_hosts:
|
||||||
|
- "office.alogins.net:host-gateway"
|
||||||
networks:
|
networks:
|
||||||
- seafile-net
|
- seafile-net
|
||||||
|
|
||||||
|
|||||||
26
syncthing/docker-compose.yml
Normal file
26
syncthing/docker-compose.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
services:
|
||||||
|
syncthing:
|
||||||
|
image: syncthing/syncthing:latest
|
||||||
|
container_name: syncthing
|
||||||
|
hostname: agap
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /mnt/misc/syncthing/config:/var/syncthing/config
|
||||||
|
- /mnt/misc/syncthing/data:/var/syncthing
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8384:8384" # web UI (proxied by Caddy)
|
||||||
|
- "22000:22000/tcp" # sync protocol TCP
|
||||||
|
- "22000:22000/udp" # sync protocol UDP
|
||||||
|
- "21027:21027/udp" # local discovery
|
||||||
|
|
||||||
|
relay:
|
||||||
|
image: syncthing/relaysrv:latest
|
||||||
|
container_name: syncthing-relay
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: relay.env
|
||||||
|
entrypoint: ["/bin/sh", "-c", "exec /bin/strelaysrv -listen=:22067 -status-srv=:22070 -pools= -token=$$RELAY_TOKEN"]
|
||||||
|
volumes:
|
||||||
|
- /mnt/misc/syncthing/relay-keys:/keys
|
||||||
|
ports:
|
||||||
|
- "22067:22067/tcp" # relay protocol
|
||||||
|
- "22070:22070/tcp" # status/metrics
|
||||||
1
syncthing/relay.env
Normal file
1
syncthing/relay.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
RELAY_TOKEN=04a4e751b42e1cec36f53ff2a99520d5f6403cdb520f7392
|
||||||
13
vikunja/docker-compose.yml
Normal file
13
vikunja/docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
vikunja:
|
||||||
|
image: vikunja/vikunja:2.2.2
|
||||||
|
environment:
|
||||||
|
VIKUNJA_SERVICE_PUBLICURL: https://todo.alogins.net/
|
||||||
|
VIKUNJA_SERVICE_JWTSECRET: 13122c95a1fa87bc5f4aefbfc415f7b6e3c2c9e9ba3f784e905c514fec19ae9d9b69
|
||||||
|
VIKUNJA_DATABASE_PATH: /db/vikunja.db
|
||||||
|
ports:
|
||||||
|
- 3457:3456
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/vikunja/files:/app/vikunja/files
|
||||||
|
- /mnt/ssd/dbs/vikunja/db:/db
|
||||||
|
restart: unless-stopped
|
||||||
277
wiki/migrate.py
277
wiki/migrate.py
@@ -1,277 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""OtterWiki → MediaWiki migration script."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
REPO = Path('/mnt/ssd/dbs/otter/app-data/repository')
|
|
||||||
API = 'http://localhost:8099/api.php'
|
|
||||||
|
|
||||||
_FN_DEF = re.compile(r'^\[(\^[^\]]+)\]:\s*(.*)')
|
|
||||||
|
|
||||||
# Cached pandoc availability (None = not yet checked)
|
|
||||||
_PANDOC_AVAILABLE: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _cap(s: str) -> str:
|
|
||||||
"""Capitalize first character, leave rest unchanged."""
|
|
||||||
return s[0].upper() + s[1:] if s else s
|
|
||||||
|
|
||||||
|
|
||||||
def _pandoc_available() -> bool:
|
|
||||||
global _PANDOC_AVAILABLE
|
|
||||||
if _PANDOC_AVAILABLE is None:
|
|
||||||
try:
|
|
||||||
_PANDOC_AVAILABLE = subprocess.run(
|
|
||||||
['pandoc', '--version'], capture_output=True
|
|
||||||
).returncode == 0
|
|
||||||
except FileNotFoundError:
|
|
||||||
_PANDOC_AVAILABLE = False
|
|
||||||
return _PANDOC_AVAILABLE
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# MediaWiki session
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def mw_login(user: str, password: str):
|
|
||||||
s = requests.Session()
|
|
||||||
r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'type': 'login', 'format': 'json'})
|
|
||||||
token = r.json()['query']['tokens']['logintoken']
|
|
||||||
s.post(API, data={'action': 'login', 'lgname': user, 'lgpassword': password,
|
|
||||||
'lgtoken': token, 'format': 'json'})
|
|
||||||
r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'format': 'json'})
|
|
||||||
csrf = r.json()['query']['tokens']['csrftoken']
|
|
||||||
return s, csrf
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Title determination
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def page_title(md_path: Path) -> str:
|
|
||||||
parts = md_path.relative_to(REPO).parts
|
|
||||||
if md_path.name == 'home.md' and len(parts) == 1:
|
|
||||||
return 'Заглавная страница'
|
|
||||||
return _cap(md_path.stem)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Markdown → wikitext conversion
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def convert_pandoc(text: str) -> str:
|
|
||||||
return subprocess.run(
|
|
||||||
['pandoc', '-f', 'markdown', '-t', 'mediawiki'],
|
|
||||||
input=text, capture_output=True, text=True
|
|
||||||
).stdout
|
|
||||||
|
|
||||||
|
|
||||||
def convert_python(text: str) -> str:
|
|
||||||
lines = text.split('\n')
|
|
||||||
|
|
||||||
# Collect footnote definitions in one pass
|
|
||||||
footnotes: dict[str, str] = {}
|
|
||||||
for line in lines:
|
|
||||||
m = _FN_DEF.match(line)
|
|
||||||
if m:
|
|
||||||
footnotes[m.group(1)] = m.group(2)
|
|
||||||
|
|
||||||
def replace_fn(m):
|
|
||||||
key = m.group(0)
|
|
||||||
return f'<ref>{footnotes.get(key, key)}</ref>'
|
|
||||||
|
|
||||||
out = []
|
|
||||||
for line in lines:
|
|
||||||
if _FN_DEF.match(line):
|
|
||||||
continue
|
|
||||||
|
|
||||||
m = re.match(r'^(#{1,6})\s+(.*)', line)
|
|
||||||
if m:
|
|
||||||
eq = '=' * len(m.group(1))
|
|
||||||
out.append(f'{eq} {m.group(2)} {eq}')
|
|
||||||
continue
|
|
||||||
|
|
||||||
if re.match(r'^---+$', line.strip()):
|
|
||||||
out.append('----')
|
|
||||||
continue
|
|
||||||
|
|
||||||
line = re.sub(r'\*\*\*(.+?)\*\*\*', r"'''''\1'''''", line)
|
|
||||||
line = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", line)
|
|
||||||
line = re.sub(r'\*(.+?)\*', r"''\1''", line)
|
|
||||||
line = re.sub(r'\[\^\S+?\]', replace_fn, line)
|
|
||||||
out.append(line)
|
|
||||||
|
|
||||||
return convert_tables('\n'.join(out))
|
|
||||||
|
|
||||||
|
|
||||||
def convert_tables(text: str) -> str:
|
|
||||||
lines = text.split('\n')
|
|
||||||
out = []
|
|
||||||
in_table = False
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
if re.match(r'^\|', line):
|
|
||||||
cells = [c.strip() for c in line.strip().strip('|').split('|')]
|
|
||||||
if all(re.match(r'^:?-+:?$', c) for c in cells if c):
|
|
||||||
if not in_table:
|
|
||||||
header_line = out.pop() if out else ''
|
|
||||||
hcells = [c.strip() for c in header_line.strip().strip('|').split('|')]
|
|
||||||
out += ['{| class="wikitable"', '|-', '! ' + ' !! '.join(hcells)]
|
|
||||||
in_table = True
|
|
||||||
out.append('|-')
|
|
||||||
else:
|
|
||||||
if in_table:
|
|
||||||
out.append('| ' + ' || '.join(cells))
|
|
||||||
else:
|
|
||||||
out.append(line)
|
|
||||||
else:
|
|
||||||
if in_table:
|
|
||||||
out.append('|}')
|
|
||||||
in_table = False
|
|
||||||
out.append(line)
|
|
||||||
|
|
||||||
if in_table:
|
|
||||||
out.append('|}')
|
|
||||||
|
|
||||||
return '\n'.join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def convert(text: str) -> str:
|
|
||||||
return convert_pandoc(text) if _pandoc_available() else convert_python(text)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Post-processing
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def fix_links(text: str) -> str:
|
|
||||||
pattern = (r'\[\[([^\]|]+)\|'
|
|
||||||
r'(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)'
|
|
||||||
r'/([^\]]+)\]\]')
|
|
||||||
|
|
||||||
def replace_link(m):
|
|
||||||
display = m.group(1).strip()
|
|
||||||
page = _cap(m.group(2).strip())
|
|
||||||
if display == page or display == (page[0].lower() + page[1:] if page else ''):
|
|
||||||
return f'[[{page}]]'
|
|
||||||
return f'[[{page}|{display}]]'
|
|
||||||
|
|
||||||
return re.sub(pattern, replace_link, text)
|
|
||||||
|
|
||||||
|
|
||||||
def fix_images(text: str) -> str:
|
|
||||||
pattern = r'!\[[^\]]*\]\(\./[^/)]+/([^)?]+)(\?[^)]*)?\)'
|
|
||||||
|
|
||||||
def replace_img(m):
|
|
||||||
filename = m.group(1)
|
|
||||||
size_m = re.search(r'thumbnail=(\d+)', m.group(2) or '')
|
|
||||||
return f'[[File:{filename}|{size_m.group(1)}px]]' if size_m else f'[[File:{filename}]]'
|
|
||||||
|
|
||||||
return re.sub(pattern, replace_img, text)
|
|
||||||
|
|
||||||
|
|
||||||
_CATEGORY_MAP = {'люди': 'Люди', 'места': 'Места', 'воспоминания': 'Воспоминания'}
|
|
||||||
|
|
||||||
|
|
||||||
def category_suffix(md_path: Path) -> str:
|
|
||||||
parts = md_path.relative_to(REPO).parts
|
|
||||||
if len(parts) == 1:
|
|
||||||
return '' if md_path.name == 'home.md' else '\n\n[[Category:Статьи]]'
|
|
||||||
cat = _CATEGORY_MAP.get(parts[0].lower())
|
|
||||||
return f'\n\n[[Category:{cat}]]' if cat else ''
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# MW operations
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def post_page(session, csrf: str, title: str, text: str, dry_run: bool) -> bool:
|
|
||||||
if dry_run:
|
|
||||||
print(f'[DRY] {title}')
|
|
||||||
return True
|
|
||||||
data = session.post(API, data={
|
|
||||||
'action': 'edit', 'title': title, 'text': text,
|
|
||||||
'token': csrf, 'format': 'json'
|
|
||||||
}).json()
|
|
||||||
if 'error' in data:
|
|
||||||
print(f'[ERR] {title}: {data["error"].get("info", data["error"])}')
|
|
||||||
return False
|
|
||||||
print(f'[OK] {title}')
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def upload_image(session, csrf: str, image_path: Path, dry_run: bool) -> bool:
|
|
||||||
basename = image_path.name
|
|
||||||
if dry_run:
|
|
||||||
print(f'[DRY] File:{basename}')
|
|
||||||
return True
|
|
||||||
with open(image_path, 'rb') as f:
|
|
||||||
data = session.post(API, data={
|
|
||||||
'action': 'upload', 'filename': basename,
|
|
||||||
'token': csrf, 'format': 'json', 'ignorewarnings': '1'
|
|
||||||
}, files={'file': f}).json()
|
|
||||||
if 'error' in data:
|
|
||||||
print(f'[ERR] File:{basename}: {data["error"].get("info", data["error"])}')
|
|
||||||
return False
|
|
||||||
if data.get('upload', {}).get('result') == 'Success':
|
|
||||||
print(f'[OK] File:{basename}')
|
|
||||||
else:
|
|
||||||
print(f'[SKIP] File:{basename} (already exists or no change)')
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Main
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def collect_images() -> list[Path]:
|
|
||||||
images = []
|
|
||||||
for folder in ('люди', 'места'):
|
|
||||||
p = REPO / folder
|
|
||||||
if p.exists():
|
|
||||||
for ext in ('*.jpg', '*.jpeg', '*.JPG', '*.JPEG', '*.png', '*.PNG'):
|
|
||||||
images.extend(p.rglob(ext))
|
|
||||||
return images
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description='Migrate OtterWiki to MediaWiki')
|
|
||||||
parser.add_argument('--user', required=True)
|
|
||||||
parser.add_argument('--password', required=True)
|
|
||||||
parser.add_argument('--dry-run', action='store_true')
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
session = csrf = None
|
|
||||||
if not args.dry_run:
|
|
||||||
session, csrf = mw_login(args.user, args.password)
|
|
||||||
|
|
||||||
pages_ok = pages_err = images_ok = images_err = 0
|
|
||||||
|
|
||||||
for md_path in sorted(REPO.rglob('*.md')):
|
|
||||||
title = page_title(md_path)
|
|
||||||
raw = md_path.read_text(encoding='utf-8')
|
|
||||||
wikitext = convert(raw)
|
|
||||||
wikitext = fix_links(wikitext)
|
|
||||||
wikitext = fix_images(wikitext)
|
|
||||||
wikitext += category_suffix(md_path)
|
|
||||||
if post_page(session, csrf, title, wikitext, args.dry_run):
|
|
||||||
pages_ok += 1
|
|
||||||
else:
|
|
||||||
pages_err += 1
|
|
||||||
|
|
||||||
for img in collect_images():
|
|
||||||
if upload_image(session, csrf, img, args.dry_run):
|
|
||||||
images_ok += 1
|
|
||||||
else:
|
|
||||||
images_err += 1
|
|
||||||
|
|
||||||
print(f'\nDone: {pages_ok} pages, {images_ok} images, {pages_err + images_err} errors')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
28
windows/docker-compose.yml
Normal file
28
windows/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
windows:
|
||||||
|
image: dockurr/windows
|
||||||
|
container_name: windows
|
||||||
|
environment:
|
||||||
|
VERSION: "tiny11"
|
||||||
|
RAM_SIZE: "2G"
|
||||||
|
CPU_CORES: "2"
|
||||||
|
DISK_SIZE: "64G"
|
||||||
|
USERNAME: "alvis"
|
||||||
|
PASSWORD: "alvis"
|
||||||
|
LANGUAGE: "English"
|
||||||
|
REGION: "en-US"
|
||||||
|
KEYBOARD: "en-US"
|
||||||
|
devices:
|
||||||
|
- /dev/kvm
|
||||||
|
- /dev/net/tun
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
ports:
|
||||||
|
- "8006:8006"
|
||||||
|
- "3389:3389/tcp"
|
||||||
|
- "3389:3389/udp"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/windows/storage:/storage
|
||||||
|
- /mnt/misc/qbittorrent/downloads:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 2m
|
||||||
@@ -17,6 +17,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "10051:10051"
|
- "10051:10051"
|
||||||
|
extra_hosts:
|
||||||
|
- "haos.alogins.net:192.168.1.3"
|
||||||
environment:
|
environment:
|
||||||
DB_SERVER_HOST: postgres-server
|
DB_SERVER_HOST: postgres-server
|
||||||
DB_SERVER_PORT: 5432
|
DB_SERVER_PORT: 5432
|
||||||
|
|||||||
Reference in New Issue
Block a user