更新内容
This commit is contained in:
+346
@@ -0,0 +1,346 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(PROJECT_ROOT, 'data'));
|
||||
const DB_PATH = path.resolve(process.env.SQLITE_DB || path.join(DATA_DIR, 'kotonoha.sqlite'));
|
||||
|
||||
let database;
|
||||
|
||||
export const sanitizeSaveName = (name) => {
|
||||
const cleaned = String(name || '')
|
||||
.replace(/\.json$/i, '')
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
||||
.trim();
|
||||
return `${cleaned || `save-${Date.now()}`}.json`;
|
||||
};
|
||||
|
||||
const normalizeTerm = (text) => String(text || '').trim();
|
||||
|
||||
const safeParseJson = (value, fallback) => {
|
||||
try {
|
||||
return value ? JSON.parse(value) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureDatabase = () => {
|
||||
if (database) return database;
|
||||
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
// better-sqlite3 is synchronous, so create the folder synchronously via mkdir from fs/promises is not suitable here.
|
||||
throw new Error(`Database directory does not exist: ${DATA_DIR}`);
|
||||
}
|
||||
|
||||
database = new Database(DB_PATH);
|
||||
database.pragma('journal_mode = WAL');
|
||||
database.pragma('foreign_keys = ON');
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_name TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
document_json TEXT NOT NULL,
|
||||
saved_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
line_id TEXT NOT NULL,
|
||||
line_index INTEGER NOT NULL,
|
||||
raw_text TEXT NOT NULL DEFAULT '',
|
||||
furigana_items_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL,
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
line_id TEXT NOT NULL,
|
||||
line_index INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
normalized_text TEXT NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT 'yellow',
|
||||
created_at INTEGER NOT NULL,
|
||||
line_text TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vocab_terms (
|
||||
normalized_text TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_normalized_text ON notes(normalized_text);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_document_id ON notes(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_lines_document_id ON lines(document_id);
|
||||
`);
|
||||
|
||||
return database;
|
||||
};
|
||||
|
||||
export const initDatabase = async () => {
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
return ensureDatabase();
|
||||
};
|
||||
|
||||
const upsertVocabTerms = (db, notes) => {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO vocab_terms (normalized_text, text, updated_at)
|
||||
VALUES (@normalizedText, @text, @updatedAt)
|
||||
ON CONFLICT(normalized_text) DO UPDATE SET
|
||||
text = excluded.text,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
for (const note of notes) {
|
||||
if (!note.normalizedText) continue;
|
||||
stmt.run({
|
||||
normalizedText: note.normalizedText,
|
||||
text: note.text,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const insertDocumentChildren = (db, documentId, document) => {
|
||||
const lines = Array.isArray(document?.lines) ? document.lines : [];
|
||||
const insertLine = db.prepare(`
|
||||
INSERT INTO lines (document_id, line_id, line_index, raw_text, furigana_items_json)
|
||||
VALUES (@documentId, @lineId, @lineIndex, @rawText, @furiganaItemsJson)
|
||||
`);
|
||||
const insertNote = db.prepare(`
|
||||
INSERT INTO notes (
|
||||
note_id, document_id, line_id, line_index, text, normalized_text, comment, color, created_at, line_text
|
||||
)
|
||||
VALUES (
|
||||
@noteId, @documentId, @lineId, @lineIndex, @text, @normalizedText, @comment, @color, @createdAt, @lineText
|
||||
)
|
||||
`);
|
||||
const allNotes = [];
|
||||
|
||||
lines.forEach((line, lineIndex) => {
|
||||
const lineId = String(line?.id || `line-${lineIndex}`);
|
||||
const rawText = typeof line?.rawText === 'string' ? line.rawText : '';
|
||||
const furiganaItems = Array.isArray(line?.furiganaItems) ? line.furiganaItems : [];
|
||||
|
||||
insertLine.run({
|
||||
documentId,
|
||||
lineId,
|
||||
lineIndex,
|
||||
rawText,
|
||||
furiganaItemsJson: JSON.stringify(furiganaItems),
|
||||
});
|
||||
|
||||
const notes = Array.isArray(line?.notes) ? line.notes : [];
|
||||
notes.forEach((note, noteIndex) => {
|
||||
const text = String(note?.text || '').trim();
|
||||
const normalizedText = normalizeTerm(text);
|
||||
if (!normalizedText) return;
|
||||
|
||||
const noteRow = {
|
||||
noteId: String(note?.id || `note-${lineIndex}-${noteIndex}`),
|
||||
documentId,
|
||||
lineId,
|
||||
lineIndex,
|
||||
text,
|
||||
normalizedText,
|
||||
comment: String(note?.comment || ''),
|
||||
color: String(note?.color || 'yellow'),
|
||||
createdAt: Number(note?.createdAt || Date.now()),
|
||||
lineText: rawText,
|
||||
};
|
||||
insertNote.run(noteRow);
|
||||
allNotes.push(noteRow);
|
||||
});
|
||||
});
|
||||
|
||||
upsertVocabTerms(db, allNotes);
|
||||
};
|
||||
|
||||
export const saveDocument = async ({ name, document }) => {
|
||||
await initDatabase();
|
||||
const db = ensureDatabase();
|
||||
const fileName = sanitizeSaveName(name || document?.title || 'current');
|
||||
const title =
|
||||
typeof document?.title === 'string' && document.title.trim()
|
||||
? document.title.trim()
|
||||
: fileName.replace(/\.json$/i, '');
|
||||
const now = new Date().toISOString();
|
||||
const documentJson = JSON.stringify(document || {});
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
const existing = db.prepare('SELECT id FROM documents WHERE file_name = ?').get(fileName);
|
||||
let documentId = existing?.id;
|
||||
|
||||
if (documentId) {
|
||||
db.prepare(`
|
||||
UPDATE documents
|
||||
SET title = ?, document_json = ?, saved_at = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(title, documentJson, now, now, documentId);
|
||||
db.prepare('DELETE FROM lines WHERE document_id = ?').run(documentId);
|
||||
db.prepare('DELETE FROM notes WHERE document_id = ?').run(documentId);
|
||||
} else {
|
||||
const result = db.prepare(`
|
||||
INSERT INTO documents (file_name, title, document_json, saved_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(fileName, title, documentJson, now, now, now);
|
||||
documentId = Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
insertDocumentChildren(db, documentId, document);
|
||||
return documentId;
|
||||
});
|
||||
|
||||
const id = transaction();
|
||||
return {
|
||||
id,
|
||||
name: fileName,
|
||||
title,
|
||||
savedAt: now,
|
||||
size: Buffer.byteLength(documentJson, 'utf-8'),
|
||||
lineCount: Array.isArray(document?.lines) ? document.lines.length : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteDocumentByFileName = async (fileName) => {
|
||||
await initDatabase();
|
||||
const db = ensureDatabase();
|
||||
db.prepare('DELETE FROM documents WHERE file_name = ?').run(sanitizeSaveName(fileName));
|
||||
};
|
||||
|
||||
export const listDocuments = async () => {
|
||||
await initDatabase();
|
||||
const db = ensureDatabase();
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
d.id,
|
||||
d.file_name AS name,
|
||||
d.title,
|
||||
d.saved_at AS savedAt,
|
||||
LENGTH(d.document_json) AS size,
|
||||
COUNT(l.id) AS lineCount
|
||||
FROM documents d
|
||||
LEFT JOIN lines l ON l.document_id = d.id
|
||||
GROUP BY d.id
|
||||
ORDER BY datetime(d.saved_at) DESC
|
||||
`).all();
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
lineCount: Number(row.lineCount || 0),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getDocumentByFileName = async (fileName) => {
|
||||
await initDatabase();
|
||||
const db = ensureDatabase();
|
||||
const row = db.prepare('SELECT document_json FROM documents WHERE file_name = ?').get(sanitizeSaveName(fileName));
|
||||
return row ? safeParseJson(row.document_json, null) : null;
|
||||
};
|
||||
|
||||
export const getDocumentById = async (id) => {
|
||||
await initDatabase();
|
||||
const db = ensureDatabase();
|
||||
const row = db.prepare('SELECT document_json FROM documents WHERE id = ?').get(Number(id));
|
||||
return row ? safeParseJson(row.document_json, null) : null;
|
||||
};
|
||||
|
||||
export const getVocabCards = async () => {
|
||||
await initDatabase();
|
||||
const db = ensureDatabase();
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
n.normalized_text AS normalizedText,
|
||||
n.text,
|
||||
n.comment,
|
||||
n.color,
|
||||
n.line_text AS lineText,
|
||||
n.line_index AS lineIndex,
|
||||
n.created_at AS createdAt,
|
||||
d.title AS documentTitle,
|
||||
d.file_name AS fileName,
|
||||
d.id AS documentId
|
||||
FROM notes n
|
||||
JOIN documents d ON d.id = n.document_id
|
||||
ORDER BY n.normalized_text ASC, n.created_at DESC
|
||||
`).all();
|
||||
|
||||
const grouped = new Map();
|
||||
for (const row of rows) {
|
||||
const key = row.normalizedText;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, {
|
||||
text: row.text,
|
||||
normalizedText: key,
|
||||
noteCount: 0,
|
||||
documentTitles: [],
|
||||
latestComment: row.comment,
|
||||
latestColor: row.color,
|
||||
entries: [],
|
||||
});
|
||||
}
|
||||
|
||||
const card = grouped.get(key);
|
||||
card.noteCount += 1;
|
||||
if (!card.documentTitles.includes(row.documentTitle)) {
|
||||
card.documentTitles.push(row.documentTitle);
|
||||
}
|
||||
card.entries.push({
|
||||
comment: row.comment,
|
||||
color: row.color,
|
||||
lineText: row.lineText,
|
||||
lineNumber: Number(row.lineIndex) + 1,
|
||||
documentTitle: row.documentTitle,
|
||||
fileName: row.fileName,
|
||||
documentId: row.documentId,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
return [...grouped.values()];
|
||||
};
|
||||
|
||||
export const migrateSavesToDatabase = async (savesDir) => {
|
||||
await initDatabase();
|
||||
await fs.mkdir(savesDir, { recursive: true });
|
||||
const entries = await fs.readdir(savesDir);
|
||||
const migrated = [];
|
||||
|
||||
for (const entry of entries.filter((item) => item.toLowerCase().endsWith('.json'))) {
|
||||
const filePath = path.join(savesDir, entry);
|
||||
try {
|
||||
const document = safeParseJson(await fs.readFile(filePath, 'utf-8'), null);
|
||||
if (!document || typeof document !== 'object') continue;
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const file = await saveDocument({
|
||||
name: entry,
|
||||
document: {
|
||||
...document,
|
||||
exportedAt: document.exportedAt || stat.mtime.toISOString(),
|
||||
},
|
||||
});
|
||||
migrated.push(file);
|
||||
} catch (error) {
|
||||
console.error(`Failed to migrate save file: ${entry}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
};
|
||||
|
||||
export const getDatabasePath = () => DB_PATH;
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import express from 'express';
|
||||
import fs from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
deleteDocumentByFileName,
|
||||
getDatabasePath,
|
||||
getDocumentByFileName,
|
||||
getDocumentById,
|
||||
getVocabCards,
|
||||
initDatabase,
|
||||
listDocuments,
|
||||
migrateSavesToDatabase,
|
||||
sanitizeSaveName,
|
||||
saveDocument,
|
||||
} from './db.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||
const PORT = Number(process.env.PORT || 3002);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const DIST_DIR = path.resolve(PROJECT_ROOT, 'dist');
|
||||
const SAVES_DIR = path.resolve(process.env.SAVES_DIR || path.join(PROJECT_ROOT, 'saves'));
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', process.env.CORS_ORIGIN || '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(204);
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
const ensureSavesDir = async () => {
|
||||
await fs.mkdir(SAVES_DIR, { recursive: true });
|
||||
};
|
||||
|
||||
const getSaveFilePath = (name) => path.join(SAVES_DIR, sanitizeSaveName(name));
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
savesDir: SAVES_DIR,
|
||||
database: getDatabasePath(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/saves', async (_req, res) => {
|
||||
try {
|
||||
await ensureSavesDir();
|
||||
const files = await listDocuments();
|
||||
res.json({ files });
|
||||
} catch (error) {
|
||||
console.error('Failed to list save files', error);
|
||||
res.status(500).json({ error: 'Failed to list save files' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/saves/:fileName', async (req, res) => {
|
||||
try {
|
||||
await ensureSavesDir();
|
||||
const document = await getDocumentByFileName(req.params.fileName);
|
||||
if (document) {
|
||||
res.json(document);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = getSaveFilePath(req.params.fileName);
|
||||
if (!existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'Save file not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
res.type('application/json').send(content);
|
||||
} catch (error) {
|
||||
console.error('Failed to read save file', error);
|
||||
res.status(500).json({ error: 'Failed to read save file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/saves', async (req, res) => {
|
||||
try {
|
||||
await ensureSavesDir();
|
||||
const fileName = sanitizeSaveName(req.body?.name || 'current');
|
||||
const document = req.body?.document;
|
||||
|
||||
if (!document || typeof document !== 'object') {
|
||||
res.status(400).json({ error: 'Missing document payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(SAVES_DIR, fileName);
|
||||
await fs.writeFile(filePath, JSON.stringify(document, null, 2), 'utf-8');
|
||||
const file = await saveDocument({ name: fileName, document });
|
||||
res.json({ file });
|
||||
} catch (error) {
|
||||
console.error('Failed to save document', error);
|
||||
res.status(500).json({ error: 'Failed to save document' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/saves/:fileName', async (req, res) => {
|
||||
try {
|
||||
await ensureSavesDir();
|
||||
const filePath = getSaveFilePath(req.params.fileName);
|
||||
if (!existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'Save file not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.unlink(filePath);
|
||||
await deleteDocumentByFileName(req.params.fileName);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete save file', error);
|
||||
res.status(500).json({ error: 'Failed to delete save file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/documents', async (_req, res) => {
|
||||
try {
|
||||
const files = await listDocuments();
|
||||
res.json({ files });
|
||||
} catch (error) {
|
||||
console.error('Failed to list documents', error);
|
||||
res.status(500).json({ error: 'Failed to list documents' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/documents/:id', async (req, res) => {
|
||||
try {
|
||||
const document = await getDocumentById(req.params.id);
|
||||
if (!document) {
|
||||
res.status(404).json({ error: 'Document not found' });
|
||||
return;
|
||||
}
|
||||
res.json(document);
|
||||
} catch (error) {
|
||||
console.error('Failed to read document', error);
|
||||
res.status(500).json({ error: 'Failed to read document' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/documents', async (req, res) => {
|
||||
try {
|
||||
const document = req.body?.document;
|
||||
if (!document || typeof document !== 'object') {
|
||||
res.status(400).json({ error: 'Missing document payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await saveDocument({ name: req.body?.name || document.title || 'current', document });
|
||||
await ensureSavesDir();
|
||||
await fs.writeFile(path.join(SAVES_DIR, file.name), JSON.stringify(document, null, 2), 'utf-8');
|
||||
res.json({ file });
|
||||
} catch (error) {
|
||||
console.error('Failed to save document', error);
|
||||
res.status(500).json({ error: 'Failed to save document' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/vocab', async (_req, res) => {
|
||||
try {
|
||||
const cards = await getVocabCards();
|
||||
res.json({ cards });
|
||||
} catch (error) {
|
||||
console.error('Failed to list vocab cards', error);
|
||||
res.status(500).json({ error: 'Failed to list vocab cards' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/vocab/:term', async (req, res) => {
|
||||
try {
|
||||
const cards = await getVocabCards();
|
||||
const card = cards.find((item) => item.normalizedText === req.params.term || item.text === req.params.term);
|
||||
if (!card) {
|
||||
res.status(404).json({ error: 'Vocab card not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ card });
|
||||
} catch (error) {
|
||||
console.error('Failed to read vocab card', error);
|
||||
res.status(500).json({ error: 'Failed to read vocab card' });
|
||||
}
|
||||
});
|
||||
|
||||
if (existsSync(DIST_DIR)) {
|
||||
app.use(express.static(DIST_DIR));
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
||||
});
|
||||
} else {
|
||||
app.get('*', (_req, res) => {
|
||||
res.status(503).send('Frontend dist not found. Run `npm run build` before starting the server.');
|
||||
});
|
||||
}
|
||||
|
||||
await initDatabase();
|
||||
await ensureSavesDir();
|
||||
const migratedFiles = await migrateSavesToDatabase(SAVES_DIR);
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`Kotonoha server listening on http://${HOST}:${PORT}`);
|
||||
console.log(`Serving frontend from: ${DIST_DIR}`);
|
||||
console.log(`Using saves directory: ${SAVES_DIR}`);
|
||||
console.log(`Using SQLite database: ${getDatabasePath()}`);
|
||||
console.log(`Indexed save files: ${migratedFiles.length}`);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getDatabasePath, migrateSavesToDatabase } from './db.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
||||
const SAVES_DIR = path.resolve(process.env.SAVES_DIR || path.join(PROJECT_ROOT, 'saves'));
|
||||
|
||||
try {
|
||||
const migrated = await migrateSavesToDatabase(SAVES_DIR);
|
||||
console.log(`Migrated ${migrated.length} save files into SQLite.`);
|
||||
console.log(`SQLite database: ${getDatabasePath()}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to migrate saves into SQLite.', error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user