Merge pull request #587 from Schnitzel5/integration/lnreader

added support for LNReader plugins
This commit is contained in:
Moustapha Kodjo Amadou 2025-10-13 10:51:40 +01:00 committed by GitHub
commit 84330d2a3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1506 additions and 4 deletions

View file

@ -4,11 +4,13 @@ import 'package:mangayomi/models/source.dart';
import 'dart/service.dart';
import 'javascript/service.dart';
import 'mihon/service.dart';
import 'lnreader/service.dart';
ExtensionService getExtensionService(Source source, String androidProxyServer) {
return switch (source.sourceCodeLanguage) {
SourceCodeLanguage.dart => DartExtensionService(source),
SourceCodeLanguage.javascript => JsExtensionService(source),
SourceCodeLanguage.mihon => MihonExtensionService(source, androidProxyServer),
SourceCodeLanguage.lnreader => LNReaderExtensionService(source),
};
}

184
lib/eval/lnreader/http.dart Normal file
View file

@ -0,0 +1,184 @@
import 'dart:convert';
import 'dart:io';
import 'package:d4rt/d4rt.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:mangayomi/services/http/m_client.dart';
import 'package:http/http.dart' as http;
class JsHttpClient {
late JavascriptRuntime runtime;
JsHttpClient(this.runtime);
void init() {
InterceptedClient client() {
return MClient.init();
}
runtime.onMessage('http_head', (dynamic args) async {
return await _toHttpResponse(client(), "HEAD", args);
});
runtime.onMessage('http_get', (dynamic args) async {
return await _toHttpResponse(client(), "GET", args);
});
runtime.onMessage('http_post', (dynamic args) async {
return await _toHttpResponse(client(), "POST", args);
});
runtime.onMessage('http_put', (dynamic args) async {
return await _toHttpResponse(client(), "PUT", args);
});
runtime.onMessage('http_delete', (dynamic args) async {
return await _toHttpResponse(client(), "DELETE", args);
});
runtime.onMessage('http_patch', (dynamic args) async {
return await _toHttpResponse(client(), "PATCH", args);
});
runtime.evaluate('''
class Response {
constructor(url, result) {
this.url = url;
this.response = JSON.parse(result);
}
get status() {
return this.response.statusCode;
}
get statusText() {
return this.response.reasonPhrase;
}
get ok() {
return this.status >= 200 && this.status <= 299;
}
get redirected() {
return this.response.isRedirect;
}
get headers() {
return this.response.headers;
}
get body() {
return this.response.body;
}
json() {
const val = JSON.parse(this.body);
return new Promise(function(resolve, reject) {
resolve(val);
});
}
text() {
const val = this.body;
return new Promise(function(resolve, reject) {
resolve(val);
});
}
}
async function fetchApi(url, init) {
const method = init?.method ? init.method.toLowerCase() : "get";
const result = await sendMessage(
"http_" + method,
JSON.stringify([url, init?.headers, init?.body])
);
return new Response(url, result);
}
''');
}
}
Future<String> _toHttpResponse(Client client, String method, List args) async {
final url = args[0] as String;
final headers = (args[1] as Map?)?.toMapStringString ?? {};
final body = args.length >= 3
? args[2] is List
? args[2] as List
: args[2] is String
? args[2] as String
: (args[2] as Map?)?.toMapStringDynamic
: null;
var request = http.Request(method, Uri.parse(url));
request.headers.addAll(headers);
if ((request.headers[HttpHeaders.contentTypeHeader]?.contains(
"application/json",
)) ??
false) {
request.body = json.encode(body);
request.headers.addAll(headers);
http.StreamedResponse response = await client.send(request);
final res = Response(
"",
response.statusCode,
request: response.request,
headers: response.headers,
isRedirect: response.isRedirect,
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
);
Map<String, dynamic> resMap = res.toJson();
resMap["body"] = await response.stream.bytesToString();
return jsonEncode(resMap);
}
String? formData;
if (body is Map && body.containsKey("_data")) {
formData = (body.get("_data") as List<dynamic>)
.map(
(e) =>
"${Uri.encodeQueryComponent(e[0])}"
"=${Uri.encodeQueryComponent(e[1])}",
)
.join("&");
headers["content-type"] =
"application/x-www-form-urlencoded; charset=UTF-8";
}
final future = switch (method) {
"HEAD" => client.head(Uri.parse(url), headers: headers),
"GET" => client.get(Uri.parse(url), headers: headers),
"POST" => client.post(
Uri.parse(url),
headers: headers,
body: formData ?? body,
),
"PUT" => client.put(
Uri.parse(url),
headers: headers,
body: formData ?? body,
),
"DELETE" => client.delete(
Uri.parse(url),
headers: headers,
body: formData ?? body,
),
_ => client.patch(Uri.parse(url), headers: headers, body: formData ?? body),
};
return jsonEncode((await future).toJson());
}
extension ResponseExtexsion on Response {
Map<String, dynamic> toJson() => {
'body': body,
'headers': headers,
'isRedirect': isRedirect,
'persistentConnection': persistentConnection,
'reasonPhrase': reasonPhrase,
'statusCode': statusCode,
'request': {
'contentLength': request?.contentLength,
'finalized': request?.finalized,
'followRedirects': request?.followRedirects,
'headers': request?.headers,
'maxRedirects': request?.maxRedirects,
'method': request?.method,
'persistentConnection': request?.persistentConnection,
'url': request?.url.toString(),
},
};
}
extension ToMapExtension on Map? {
Map<String, dynamic>? get toMapStringDynamic {
return this?.map((key, value) => MapEntry(key.toString(), value));
}
Map<String, String>? get toMapStringString {
return this?.map(
(key, value) => MapEntry(key.toString(), value.toString()),
);
}
}

View file

@ -0,0 +1,414 @@
import 'dart:convert';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:html/dom.dart';
import 'package:html/parser.dart';
import 'package:mangayomi/utils/extensions/dom_extensions.dart';
class JsCheerio {
late JavascriptRuntime runtime;
final Map<int, Element?> _elements = {};
int _elementKey = 0;
JsCheerio(this.runtime);
void init() {
runtime.onMessage('load', (dynamic args) {
final html = args[0];
final doc = parse(html);
_elementKey++;
_elements[_elementKey] = doc.body;
return _elementKey;
});
runtime.onMessage('element_call', (dynamic args) {
final method = args[0] as String;
final key = args[1] as int;
final element = _elements[key];
if (element == null) return null;
final methodArgs = args.length > 2 ? args[2] : [];
dynamic result;
switch (method) {
case 'text':
result = element.text;
break;
case 'html':
case 'innerHtml':
result = element.innerHtml;
break;
case 'outerHtml':
result = element.outerHtml;
break;
case 'addClass':
element.classes.add(methodArgs[0]);
break;
case 'removeClass':
element.classes.remove(methodArgs[0]);
break;
case 'hasClass':
result = element.classes.contains(methodArgs[0]);
break;
case 'attr':
result = element.attributes[methodArgs[0]] ?? '';
break;
case 'setAttr':
element.attributes[methodArgs[0]] = methodArgs[1];
break;
case 'removeAttr':
element.attributes.remove(methodArgs[0]);
break;
case 'val':
result = element.attributes['value'] ?? '';
break;
case 'setVal':
element.attributes['value'] = methodArgs[0];
break;
case 'children':
final children = element.children;
List<int> keys = [];
for (var child in children) {
_elementKey++;
_elements[_elementKey] = child;
keys.add(_elementKey);
}
result = jsonEncode(keys);
break;
case 'parent':
final parent = element.parent;
if (parent != null) {
_elementKey++;
_elements[_elementKey] = parent;
result = _elementKey;
}
break;
case 'find':
final selector = methodArgs[0];
final elements = element.select(selector);
List<int> keys = [];
for (var el in elements ?? []) {
_elementKey++;
_elements[_elementKey] = el;
keys.add(_elementKey);
}
result = jsonEncode(keys);
break;
case 'first':
final first = element.children.firstOrNull;
if (first != null) {
_elementKey++;
_elements[_elementKey] = first;
result = _elementKey;
}
break;
case 'last':
final last = element.children.lastOrNull;
if (last != null) {
_elementKey++;
_elements[_elementKey] = last;
result = _elementKey;
}
break;
case 'next':
final next = element.nextElementSibling;
if (next != null) {
_elementKey++;
_elements[_elementKey] = next;
result = _elementKey;
}
break;
case 'prev':
final prev = element.previousElementSibling;
if (prev != null) {
_elementKey++;
_elements[_elementKey] = prev;
result = _elementKey;
}
break;
case 'append':
final htmlToAppend = methodArgs[0];
final newNodes = parse(htmlToAppend).children;
for (var node in newNodes) {
element.append(node);
}
break;
case 'prepend':
final htmlToPrepend = methodArgs[0];
final newNodes = parse(htmlToPrepend).children;
for (var node in newNodes.reversed) {
element.insertBefore(node, element.firstChild);
}
break;
case 'empty':
element.children.clear();
break;
case 'remove':
element.remove();
break;
default:
result = 'Unsupported method: $method';
}
return result;
});
runtime.evaluate('''
class Element {
constructor(key) {
this._key = key;
}
_call(method, args = []) {
return sendMessage("element_call", JSON.stringify([method, this._key, args]));
}
text() { return this._call("text"); }
html() { return this._call("html"); }
outerHtml() { return this._call("outerHtml"); }
val() { return this._call("val"); }
attr(name) { return this._call("attr", [name]); }
hasClass(cls) { return this._call("hasClass", [cls]); }
addClass(cls) { this._call("addClass", [cls]); return this; }
removeClass(cls) { this._call("removeClass", [cls]); return this; }
setAttr(name, value) { this._call("setAttr", [name, value]); return this; }
removeAttr(name) { this._call("removeAttr", [name]); return this; }
setVal(value) { this._call("setVal", [value]); return this; }
append(html) { this._call("append", [html]); return this; }
prepend(html) { this._call("prepend", [html]); return this; }
empty() { this._call("empty"); return this; }
remove() { this._call("remove"); return this; }
children() {
const keys = JSON.parse(this._call("children"));
return new ElementCollection(keys.map(k => new Element(k)));
}
find(selector) {
const keys = JSON.parse(this._call("find", [selector]));
return new ElementCollection(keys.map(k => new Element(k)));
}
parent() {
return new Element(this._call("parent"));
}
next() {
return new Element(this._call("next"));
}
prev() {
return new Element(this._call("prev"));
}
first() {
return new Element(this._call("first"));
}
last() {
return new Element(this._call("last"));
}
}
class ElementCollection {
constructor(elements) {
this.elements = elements;
}
text() {
return this.map(function(i, el) {
return el.text();
}).toArray().join("\\n") ?? "";
}
html() {
return this.first()?.html();
}
outerHtml() {
return this.first()?.outerHtml();
}
attr(name) {
return this.first()?.attr(name);
}
hasClass(cls) {
return this.first()?.hasClass(cls);
}
each(fn) {
this.elements.forEach((el, i) => fn(i, el));
return this;
}
map(fn) {
return new ElementCollection(this.elements.map((el, i) => fn(i, el)));
}
filter(fn) {
return new ElementCollection(this.elements.filter(function (el, i) {
try {
return fn(i, el);
} catch (_) {
return false;
}
}));
}
addClass(cls) {
this.elements.forEach(el => el.addClass(cls));
return this;
}
removeClass(cls) {
this.elements.forEach(el => el.removeClass(cls));
return this;
}
setAttr(name, value) {
this.elements.forEach(el => el.setAttr(name, value));
return this;
}
removeAttr(name) {
this.elements.forEach(el => el.removeAttr(name));
return this;
}
setVal(value) {
this.elements.forEach(el => el.setVal(value));
return this;
}
append(html) {
this.elements.forEach(el => el.append(html));
return this;
}
prepend(html) {
this.elements.forEach(el => el.prepend(html));
return this;
}
empty() {
this.elements.forEach(el => el.empty());
return this;
}
remove() {
this.elements.forEach(el => el.remove());
return this;
}
find(selector) {
const found = this.elements.flatMap(el => {
const keys = JSON.parse(el._call("find", [selector]));
return keys.map(k => new Element(k));
});
return new ElementCollection(found);
}
children() {
const children = this.elements.flatMap(el => {
const keys = JSON.parse(el._call("children"));
return keys.map(k => new Element(k));
});
return new ElementCollection(children);
}
parent() {
const parents = this.elements.map(el => new Element(el._call("parent")));
return new ElementCollection(parents);
}
next() {
const nextEls = this.elements.map(el => new Element(el._call("next")));
return new ElementCollection(nextEls);
}
prev() {
const prevEls = this.elements.map(el => new Element(el._call("prev")));
return new ElementCollection(prevEls);
}
first() {
return this.get(0);
}
last() {
return this.get(this.elements.length - 1);
}
get(index) {
return this.elements[index] || new Stub();
}
length() {
return this.elements.length;
}
toArray() {
return this.elements;
}
[Symbol.iterator]() {
return this.elements[Symbol.iterator]();
}
}
class Stub {
text() {
return null;
}
html() {
return null;
}
outerHtml() {
return null;
}
val() {
return null;
}
attr(name) {
return null;
}
hasClass(cls) {
return false;
}
}
function load(html) {
const rootKey = sendMessage("load", JSON.stringify([html]));
const root = new Element(rootKey);
const \$ = function(input) {
if (input instanceof ElementCollection) {
return input;
} else if (input instanceof Element) {
return input;
} else if (typeof input === "string") {
return root.find(input); // returns ElementCollection
} else if (input && input._key) {
return new ElementCollection([new Element(input._key)]);
} else {
return new ElementCollection([new Element(input)]);
}
};
\$.html = function() {
return root.html();
};
\$.root = root;
\$.Element = Element;
\$.Collection = ElementCollection;
return \$;
}
''');
}
}

View file

@ -0,0 +1,202 @@
import 'package:flutter_qjs/flutter_qjs.dart';
class JsHtmlParser {
late JavascriptRuntime runtime;
JsHtmlParser(this.runtime);
void init() {
runtime.evaluate('''
class Parser {
constructor(options = {}) {
this.options = options;
this.buffer = '';
}
isVoidElement(name) {
return [
"area",
"base",
"basefont",
"br",
"col",
"command",
"embed",
"frame",
"hr",
"img",
"input",
"isindex",
"keygen",
"link",
"meta",
"param",
"source",
"track",
"wbr",
].includes(name);
}
write(html) {
this.buffer += html;
let i = 0;
let textStart = 0;
const len = this.buffer.length;
let insideQuote = null;
while (i < len) {
const ch = this.buffer[i];
// Track string literals
if ((ch === '"' || ch === "'")) {
if (insideQuote === ch) {
insideQuote = null;
} else if (insideQuote === null) {
insideQuote = ch;
}
i++;
continue;
}
if (ch === '<' && insideQuote === null) {
// Emit any text before the tag
if (i > textStart && this.options.ontext) {
const text = this.buffer.slice(textStart, i);
this.options.ontext(text);
}
const tagStart = i;
i++;
const isClosing = this.buffer[i] === '/';
if (isClosing) i++;
// Parse tag name
const nameStart = i;
while (i < len && /[a-zA-Z0-9:-]/.test(this.buffer[i])) i++;
const nameEnd = i;
const tagName = this.buffer.slice(nameStart, nameEnd);
if (isClosing) {
if (this.options.onclosetag) {
this.options.onclosetag(tagName);
}
} else {
if (this.options.onopentagname) {
this.options.onopentagname(tagName);
}
}
// Parse attributes
let attrs = {};
let attrName = '';
let attrValue = '';
let readingAttrName = true;
let inAttrQuote = null;
while (i < len && this.buffer[i] !== '>') {
const c = this.buffer[i];
// Skip over whitespace
if (/\\s/.test(c)) {
i++;
continue;
}
// Handle self-closing tag
if (c === '/' && this.buffer[i + 1] === '>') {
if (!isClosing && this.options.onselfclosingtag) {
this.options.onselfclosingtag();
}
i += 2;
textStart = i;
if (this.options.onopentag) {
this.options.onopentag(tagName, attrs);
}
if (this.options.onopentagend) {
this.options.onopentagend();
}
continue;
}
// Parse attribute name
let attrStart = i;
while (i < len && /[^\\s=>]/.test(this.buffer[i])) i++;
attrName = this.buffer.slice(attrStart, i);
// Skip whitespace after name
while (i < len && /\\s/.test(this.buffer[i])) i++;
// Expect '='
if (this.buffer[i] === '=') {
i++; // skip '='
// Skip whitespace after '='
while (i < len && /\\s/.test(this.buffer[i])) i++;
const quote = this.buffer[i];
if (quote === '"' || quote === "'") {
inAttrQuote = quote;
i++; // skip quote
const valStart = i;
while (i < len && this.buffer[i] !== inAttrQuote) i++;
attrValue = this.buffer.slice(valStart, i);
i++; // skip closing quote
} else {
// Unquoted value
const valStart = i;
while (i < len && /[^\\s>]/.test(this.buffer[i])) i++;
attrValue = this.buffer.slice(valStart, i);
}
// Emit merged attribute callback
if (this.options.onattribute) {
this.options.onattribute(attrName, attrValue);
}
attrs[attrName] = attrValue;
attrName = '';
attrValue = '';
} else {
// attribute without value (e.g. `disabled`)
if (this.options.onattribute) {
this.options.onattribute(attrName, null);
}
attrs[attrName] = null;
attrName = '';
}
}
// Closing normal tag '>'
i++; // skip '>'
if (!isClosing && this.options.onopentag) {
this.options.onopentag(tagName, attrs);
}
if (this.options.onopentagend) {
this.options.onopentagend();
}
textStart = i;
} else {
i++;
}
}
// Emit any remaining text
if (textStart < len && this.options.ontext) {
const text = this.buffer.slice(textStart, len);
this.options.ontext(text);
}
}
end() {
if (this.options.onend) {
this.options.onend();
}
}
}
''');
}
}

View file

@ -0,0 +1,159 @@
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:mangayomi/utils/log/log.dart';
class JsLibs {
late JavascriptRuntime runtime;
JsLibs(this.runtime);
void init() {
runtime.onMessage('log', (dynamic args) {
Logger.add(LoggerLevel.warning, "${args[0]}");
return null;
});
runtime.onMessage('urlencode', (dynamic args) {
return Uri.encodeComponent(args[0]);
});
runtime.onMessage('urldecode', (dynamic args) {
return Uri.decodeComponent(args[0]);
});
runtime.evaluate('''
console.log = function (message) {
if (typeof message === "object") {
message = JSON.stringify(message);
}
sendMessage("log", JSON.stringify([message.toString()]));
};
console.warn = function (message) {
if (typeof message === "object") {
message = JSON.stringify(message);
}
sendMessage("log", JSON.stringify([message.toString()]));
};
console.error = function (message) {
if (typeof message === "object") {
message = JSON.stringify(message);
}
sendMessage("log", JSON.stringify([message.toString()]));
};
String.prototype.substringAfter = function(pattern) {
const startIndex = this.indexOf(pattern);
if (startIndex === -1) return this.substring(0);
const start = startIndex + pattern.length;
return this.substring(start);
}
String.prototype.substringAfterLast = function(pattern) {
return this.split(pattern).pop();
}
String.prototype.substringBefore = function(pattern) {
const endIndex = this.indexOf(pattern);
if (endIndex === -1) return this.substring(0);
return this.substring(0, endIndex);
}
String.prototype.substringBeforeLast = function(pattern) {
const endIndex = this.lastIndexOf(pattern);
if (endIndex === -1) return this.substring(0);
return this.substring(0, endIndex);
}
String.prototype.substringBetween = function(left, right) {
let startIndex = 0;
let index = this.indexOf(left, startIndex);
if (index === -1) return "";
let leftIndex = index + left.length;
let rightIndex = this.indexOf(right, leftIndex);
if (rightIndex === -1) return "";
startIndex = rightIndex + right.length;
return this.substring(leftIndex, rightIndex);
}
async function jsonStringify(fn) {
return JSON.stringify(await fn());
}
const isUrlAbsolute = url => {
if (url) {
if (url.indexOf("//") === 0) {
return true
} // URL is protocol-relative (= absolute)
if (url.indexOf("://") === -1) {
return false
} // URL has no protocol (= relative)
if (url.indexOf(".") === -1) {
return false
} // URL does not contain a dot, i.e. no TLD (= relative, possibly REST)
if (url.indexOf("/") === -1) {
return false
} // URL does not contain a single slash (= relative)
if (url.indexOf(":") > url.indexOf("/")) {
return false
} // The first colon comes after the first slash (= relative)
if (url.indexOf("://") < url.indexOf(".")) {
return true
} // Protocol is defined before first dot (= absolute)
}
return false // Anything else must be relative
}
const NovelStatus = {
"Unknown": "Unknown",
"Ongoing": "Ongoing",
"Completed": "Completed",
"Licensed": "Licensed",
"PublishingFinished": "Publishing Finished",
"Cancelled": "Cancelled",
"OnHiatus": "On Hiatus"
};
const FilterTypes = {
"TextInput": "Text",
"Picker": "Picker",
"CheckboxGroup": "Checkbox",
"Switch": "Switch",
"ExcludableCheckboxGroup": "XCheckbox"
};
const isPickerValue = q => {
return q.type === FilterTypes.Picker && typeof q.value === "string"
}
const isCheckboxValue = q => {
return q.type === FilterTypes.CheckboxGroup && Array.isArray(q.value)
}
const isSwitchValue = q => {
return q.type === FilterTypes.Switch && typeof q.value === "boolean"
}
const isTextValue = q => {
return q.type === FilterTypes.TextInput && typeof q.value === "string"
}
const isXCheckboxValue = q => {
return (
q.type === FilterTypes.ExcludableCheckboxGroup &&
typeof q.value === "object" &&
!Array.isArray(q.value)
)
}
function urlencode(input) {
return sendMessage(
"urlencode",
JSON.stringify([input])
);
}
function urldecode(input) {
return sendMessage(
"urldecode",
JSON.stringify([input])
);
}
''');
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,136 @@
class ChapterItem {
String name;
String path;
String? releaseTime;
int? chapterNumber;
String? page;
ChapterItem({
required this.name,
required this.path,
this.releaseTime,
this.chapterNumber,
this.page,
});
factory ChapterItem.fromJson(Map<String, dynamic> json) {
return ChapterItem(
name: json['name'],
path: json['path'],
releaseTime: json['releaseTime'],
chapterNumber: json['chapterNumber'] != null
? (json['chapterNumber'] as num?)?.toInt() ??
int.tryParse(json['chapterNumber'])
: null,
page: json['page'],
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'path': path,
'releaseTime': releaseTime,
'chapterNumber': chapterNumber,
'page': page,
};
}
}
class NovelItem {
String name;
String path;
String? cover;
NovelItem({required this.name, required this.path, this.cover});
factory NovelItem.fromJson(Map<String, dynamic> json) {
return NovelItem(
name: json['name'],
path: json['path'],
cover: json['cover'],
);
}
Map<String, dynamic> toJson() {
return {'name': name, 'path': path, 'cover': cover};
}
}
class SourceNovel extends NovelItem {
String? genres;
String? summary;
String? author;
String? artist;
String? status;
double? rating;
List<ChapterItem>? chapters;
SourceNovel({
required super.name,
required super.path,
super.cover,
this.genres,
this.summary,
this.author,
this.artist,
this.status,
this.rating,
this.chapters,
});
factory SourceNovel.fromJson(Map<String, dynamic> json) {
return SourceNovel(
name: json['name'],
path: json['path'],
cover: json['cover'],
genres: json['genres'],
summary: json['summary'],
author: json['author'],
artist: json['artist'],
status: json['status'],
rating: json['rating'] is double
? json['rating']
: json['rating']?.toDouble(),
chapters: (json['chapters'] as List<dynamic>?)
?.map((item) => ChapterItem.fromJson(item))
.toList(),
);
}
@override
Map<String, dynamic> toJson() {
return {
'name': name,
'path': path,
'cover': cover,
'genres': genres,
'summary': summary,
'author': author,
'artist': artist,
'status': status,
'rating': rating,
'chapters': chapters?.map((item) => item.toJson()).toList(),
};
}
}
class SourcePage {
List<ChapterItem> chapters;
SourcePage({required this.chapters});
factory SourcePage.fromJson(Map<String, dynamic> json) {
return SourcePage(
chapters:
(json['chapters'] as List<dynamic>?)
?.map((item) => ChapterItem.fromJson(item))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {'chapters': chapters.map((item) => item.toJson()).toList()};
}
}

View file

@ -0,0 +1,279 @@
import 'dart:convert';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:mangayomi/eval/lnreader/http.dart';
import 'package:mangayomi/eval/lnreader/m_plugin.dart';
import 'package:mangayomi/eval/model/filter.dart';
import 'package:mangayomi/eval/model/m_chapter.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
import 'package:mangayomi/eval/model/m_pages.dart';
import 'package:mangayomi/eval/model/source_preference.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/page.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/models/video.dart';
import '../interface.dart';
import 'js_cheerio.dart';
import 'js_htmlparser.dart';
import 'js_libs.dart';
import 'js_polyfills.dart';
JavascriptRuntime getJavascriptRuntime({
Map<String, dynamic>? extraArgs = const {},
}) {
JavascriptRuntime runtime;
runtime = QuickJsRuntime2(stackSize: 1024 * 1024 * 4);
runtime.enableHandlePromises();
return runtime;
}
class LNReaderExtensionService implements ExtensionService {
late JavascriptRuntime runtime;
@override
late Source source;
LNReaderExtensionService(this.source);
void _init() {
runtime = getJavascriptRuntime();
runtime.evaluate('''
module={},exports=Function("return this")(),Object.defineProperties(module,{namespace:{set:function(a){exports=a}},exports:{set:function(a){for(var b in a)a.hasOwnProperty(b)&&(exports[b]=a[b])},get:function(){return exports}}});
''');
JsPolyfills(runtime).init();
JsHttpClient(runtime).init();
JsLibs(runtime).init();
JsHtmlParser(runtime).init();
JsCheerio(runtime).init();
runtime.evaluate('''
const require = (package) => {
switch (package) {
case "htmlparser2":
return {Parser: Parser};
case "cheerio":
return {load: load};
case "dayjs":
return module.exports.dayjs;
case "urlencode":
return {encode: urlencode, decode: urldecode};
case "@libs/fetch":
return {fetchApi: fetchApi};
case "@libs/novelStatus":
return {NovelStatus: NovelStatus};
case "@libs/isAbsoluteUrl":
return {isUrlAbsolute: isUrlAbsolute};
case "@libs/filterInputs":
return {
FilterTypes: FilterTypes,
isPickerValue: isPickerValue,
isCheckboxValue: isCheckboxValue,
isSwitchValue: isSwitchValue,
isTextValue: isTextValue,
isXCheckboxValue: isXCheckboxValue
};
case "@libs/defaultCover":
return {defaultCover: 'https://raw.githubusercontent.com/LNReader/lnreader-plugins/refs/heads/master/public/static/coverNotAvailable.webp'};
case "@libs/storage":
return {storage: {get: () => null}};
default:
return {};
}
};
''');
runtime.evaluate('''
${source.sourceCode}
const extension = exports.default;
''');
}
@override
Map<String, String> getHeaders() {
return {};
}
@override
bool get supportsLatest {
return true;
}
@override
String get sourceBaseUrl {
return source.baseUrl!;
}
@override
Future<MPages> getPopular(int page) async {
final items =
((await _extensionCallAsync(
'popularNovels($page, {showLatestNovels: false, filters: extension.filters})',
[],
)))
.map((e) => NovelItem.fromJson(e))
.map(
(e) => MManga(
name: e.name,
imageUrl: e.cover,
link: e.path,
chapters: [],
),
)
.toList();
return MPages(list: items, hasNextPage: true);
}
@override
Future<MPages> getLatestUpdates(int page) async {
final items =
((await _extensionCallAsync(
'popularNovels($page, {showLatestNovels: true, filters: extension.filters})',
[],
)))
.map((e) => NovelItem.fromJson(e))
.map(
(e) => MManga(
name: e.name,
imageUrl: e.cover,
link: e.path,
chapters: [],
),
)
.toList();
return MPages(list: items, hasNextPage: true);
}
@override
Future<MPages> search(String query, int page, List<dynamic> filters) async {
final items =
((await _extensionCallAsync('searchNovels("$query",$page)', [])))
.map((e) => NovelItem.fromJson(e))
.map(
(e) => MManga(
name: e.name,
imageUrl: e.cover,
link: e.path,
chapters: [],
),
)
.toList();
return MPages(list: items, hasNextPage: true);
}
@override
Future<MManga> getDetail(String url) async {
final item = SourceNovel.fromJson(
await _extensionCallAsync('parseNovel(`$url`)', {}),
);
final chapters = SourcePage.fromJson(
await _extensionCallAsync('parsePage(`${item.path}`, `1`)', {}),
);
return MManga(
name: item.name,
imageUrl: item.cover,
link: item.path,
artist: item.artist,
author: item.author,
description: item.summary,
status: switch (item.status) {
"Ongoing" => Status.ongoing,
"Completed" => Status.completed,
_ => Status.unknown,
},
genre: item.genres?.split(","),
chapters:
(chapters.chapters.isNotEmpty ? chapters.chapters : item.chapters)
?.map(
(e) => MChapter(
name: e.name,
url: e.path,
dateUpload: e.releaseTime != null
? DateTime.tryParse(
e.releaseTime!,
)?.millisecondsSinceEpoch.toString() ??
int.tryParse(e.releaseTime!)?.toString() ??
DateTime.now().millisecondsSinceEpoch.toString()
: DateTime.now().millisecondsSinceEpoch.toString(),
),
)
.toList() ??
[],
);
}
@override
Future<List<PageUrl>> getPageList(String url) async {
return [];
}
@override
Future<List<Video>> getVideoList(String url) async {
return [];
}
@override
Future<String> getHtmlContent(String name, String url) async {
_init();
final res = (await runtime.handlePromise(
await runtime.evaluateAsync(
'jsonStringify(() => extension.parseChapter(`$url`))',
),
)).stringResult;
return res;
}
@override
Future<String> cleanHtmlContent(String html) async {
return html;
}
@override
FilterList getFilterList() {
List<dynamic> list;
try {
list = fromJsonFilterValuesToList(_extensionCall('filters', []));
} catch (_) {
list = [];
}
return FilterList(list);
}
@override
List<SourcePreference> getSourcePreferences() {
return _extensionCall(
'pluginSettings',
[],
).map((e) => SourcePreference.fromJson(e)..sourceId = source.id).toList();
}
T _extensionCall<T>(String call, T def) {
_init();
try {
final res = runtime.evaluate('JSON.stringify(extension.$call)');
return jsonDecode(res.stringResult) as T;
} catch (_) {
if (def != null) {
return def;
}
rethrow;
}
}
Future<T> _extensionCallAsync<T>(String call, T def) async {
_init();
try {
final promised = await runtime.handlePromise(
await runtime.evaluateAsync('jsonStringify(() => extension.$call)'),
);
return jsonDecode(promised.stringResult) as T;
} catch (e) {
if (def != null) {
return def;
}
rethrow;
}
}
}

View file

@ -135,7 +135,7 @@ class Source {
filterList = json['filterList'];
preferenceList = json['preferenceList'];
iconUrl = json['iconUrl'];
id = json['id'];
id = json['id'] is int ? json['id'] : null;
isActive = json['isActive'];
isAdded = json['isAdded'];
isFullData = json['isFullData'];
@ -216,4 +216,9 @@ class Source {
}
}
enum SourceCodeLanguage { dart, javascript, mihon }
enum SourceCodeLanguage {
dart,
javascript,
mihon,
lnreader
}

View file

@ -475,11 +475,13 @@ const _SourcesourceCodeLanguageEnumValueMap = {
'dart': 0,
'javascript': 1,
'mihon': 2,
'lnreader': 3,
};
const _SourcesourceCodeLanguageValueEnumMap = {
0: SourceCodeLanguage.dart,
1: SourceCodeLanguage.javascript,
2: SourceCodeLanguage.mihon,
3: SourceCodeLanguage.lnreader,
};
Id _sourceGetId(Source object) {

View file

@ -26,7 +26,11 @@ class _CreateExtensionState extends State<CreateExtension> {
int _languageIndex = 0;
final List<String> _sourceTypes = ["single", "multi", "torrent"];
final List<String> _itemTypes = ["Manga", "Anime", "Novel"];
final List<String> _languages = ["Dart", "JavaScript"];
final List<String> _languages = [
"Dart",
"JavaScript",
"LNReader compiled JS",
];
SourceCodeLanguage _sourceCodeLanguage = SourceCodeLanguage.dart;
@override
Widget build(BuildContext context) {
@ -67,9 +71,11 @@ class _CreateExtensionState extends State<CreateExtension> {
setState(() {
if (v == 0) {
_sourceCodeLanguage = SourceCodeLanguage.dart;
} else {
} else if (v == 1) {
_sourceCodeLanguage =
SourceCodeLanguage.javascript;
} else {
_sourceCodeLanguage = SourceCodeLanguage.lnreader;
}
_languageIndex = v!;
});

View file

@ -75,6 +75,39 @@ Future<void> fetchSourcesList({
src.id = 'mihon-${source['id']}'.hashCode;
yield src;
}
} else if (e['id'] is String &&
e['name'] != null &&
e['site'] != null &&
e['lang'] != null &&
e['version'] != null &&
e['url'] != null &&
e['iconUrl'] != null) {
final src = Source.fromJson(e)
..apiUrl = ''
..appMinVerReq = ''
..dateFormat = ''
..dateFormatLocale = ''
..hasCloudflare = false
..headers = ''
..isActive = true
..isAdded = false
..isFullData = false
..isNsfw = false
..isPinned = false
..lastUsed = false
..sourceCode = ''
..typeSource = ''
..versionLast = '0.0.1'
..isObsolete = false
..isLocal = false
..lang = _convertLang(e)
..baseUrl = e['site']
..sourceCodeUrl = e['url']
..sourceCodeLanguage = SourceCodeLanguage.lnreader
..itemType = ItemType.novel
..notes = "Performance might be poor due to limited engine";
src.id = 'lnreader-plugin-"${src.name}"."${src.lang}"'.hashCode;
yield src;
} else {
yield Source.fromJson(e);
}
@ -447,3 +480,44 @@ Future<List<SourcePreference>?> fetchPreferencesDalvik(
return null;
}
}
String _convertLang(dynamic e) {
final lang = e['lang'];
if (lang is String) {
switch (lang) {
case "‎العربية":
return "ar";
case "中文, 汉语, 漢語":
return "zh";
case "English":
return "en";
case "Français":
return "fr";
case "Bahasa Indonesia":
return "id";
case "日本語":
return "ja";
case "조선말, 한국어":
return "ko";
case "Polski":
return "pl";
case "Português":
return "pt";
case "Русский":
return "ru";
case "Español":
return "es";
case "ไทย":
return "th";
case "Türkçe":
return "tr";
case "Українська":
return "uk";
case "Tiếng Việt":
return "vi";
default:
return "all";
}
}
return "all";
}