7 Commits

Author SHA1 Message Date
d24a522b32 Added php prettier plugin 2025-09-12 20:15:56 +08:00
41afb834ae Added sql prettier plugin 2025-09-12 00:52:19 +08:00
b745329e26 Added golang prettier plugin 2025-09-11 20:42:39 +08:00
1fb4f64cb3 Add update notifications 2025-09-06 01:21:02 +08:00
1f8e8981ce 🐛 Fixed version generation issues 2025-09-05 22:40:09 +08:00
a257d30dba 🎨 Modify configuration migration policy 2025-09-05 22:07:00 +08:00
97ee3b0667 🐛 Fixed configuration merge override issue 2025-09-05 00:36:33 +08:00
54 changed files with 12453 additions and 429 deletions

View File

@@ -0,0 +1,64 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Service represents the notifications service
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* RemoveBadge removes the badge label from the application icon.
*/
export function RemoveBadge(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2374916939) as any;
return $resultPromise;
}
/**
* ServiceName returns the name of the service.
*/
export function ServiceName(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(2428202016) as any;
return $resultPromise;
}
/**
* ServiceShutdown is called when the service is unloaded.
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3893755233) as any;
return $resultPromise;
}
/**
* ServiceStartup is called when the service is loaded.
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(4078800764, options) as any;
return $resultPromise;
}
/**
* SetBadge sets the badge label on the application icon.
*/
export function SetBadge(label: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(784276339, label) as any;
return $resultPromise;
}
export function SetCustomBadge(label: string, options: $models.Options): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3058653106, label, options) as any;
return $resultPromise;
}

View File

@@ -0,0 +1,9 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as BadgeService from "./badgeservice.js";
export {
BadgeService
};
export * from "./models.js";

View File

@@ -0,0 +1,58 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as color$0 from "../../../../../../../image/color/models.js";
export class Options {
"TextColour": color$0.RGBA;
"BackgroundColour": color$0.RGBA;
"FontName": string;
"FontSize": number;
"SmallFontSize": number;
/** Creates a new Options instance. */
constructor($$source: Partial<Options> = {}) {
if (!("TextColour" in $$source)) {
this["TextColour"] = (new color$0.RGBA());
}
if (!("BackgroundColour" in $$source)) {
this["BackgroundColour"] = (new color$0.RGBA());
}
if (!("FontName" in $$source)) {
this["FontName"] = "";
}
if (!("FontSize" in $$source)) {
this["FontSize"] = 0;
}
if (!("SmallFontSize" in $$source)) {
this["SmallFontSize"] = 0;
}
Object.assign(this, $$source);
}
/**
* Creates a new Options instance from a string or object.
*/
static createFrom($$source: any = {}): Options {
const $$createField0_0 = $$createType0;
const $$createField1_0 = $$createType0;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("TextColour" in $$parsedSource) {
$$parsedSource["TextColour"] = $$createField0_0($$parsedSource["TextColour"]);
}
if ("BackgroundColour" in $$parsedSource) {
$$parsedSource["BackgroundColour"] = $$createField1_0($$parsedSource["BackgroundColour"]);
}
return new Options($$parsedSource as Partial<Options>);
}
}
// Private type creation functions
const $$createType0 = color$0.RGBA.createFrom;

View File

@@ -0,0 +1,9 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as NotificationService from "./notificationservice.js";
export {
NotificationService
};
export * from "./models.js";

View File

@@ -0,0 +1,107 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
/**
* NotificationAction represents an action button for a notification.
*/
export class NotificationAction {
"id"?: string;
"title"?: string;
/**
* (macOS-specific)
*/
"destructive"?: boolean;
/** Creates a new NotificationAction instance. */
constructor($$source: Partial<NotificationAction> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new NotificationAction instance from a string or object.
*/
static createFrom($$source: any = {}): NotificationAction {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new NotificationAction($$parsedSource as Partial<NotificationAction>);
}
}
/**
* NotificationCategory groups actions for notifications.
*/
export class NotificationCategory {
"id"?: string;
"actions"?: NotificationAction[];
"hasReplyField"?: boolean;
"replyPlaceholder"?: string;
"replyButtonTitle"?: string;
/** Creates a new NotificationCategory instance. */
constructor($$source: Partial<NotificationCategory> = {}) {
Object.assign(this, $$source);
}
/**
* Creates a new NotificationCategory instance from a string or object.
*/
static createFrom($$source: any = {}): NotificationCategory {
const $$createField1_0 = $$createType1;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("actions" in $$parsedSource) {
$$parsedSource["actions"] = $$createField1_0($$parsedSource["actions"]);
}
return new NotificationCategory($$parsedSource as Partial<NotificationCategory>);
}
}
/**
* NotificationOptions contains configuration for a notification
*/
export class NotificationOptions {
"id": string;
"title": string;
/**
* (macOS and Linux only)
*/
"subtitle"?: string;
"body"?: string;
"categoryId"?: string;
"data"?: { [_: string]: any };
/** Creates a new NotificationOptions instance. */
constructor($$source: Partial<NotificationOptions> = {}) {
if (!("id" in $$source)) {
this["id"] = "";
}
if (!("title" in $$source)) {
this["title"] = "";
}
Object.assign(this, $$source);
}
/**
* Creates a new NotificationOptions instance from a string or object.
*/
static createFrom($$source: any = {}): NotificationOptions {
const $$createField5_0 = $$createType2;
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
if ("data" in $$parsedSource) {
$$parsedSource["data"] = $$createField5_0($$parsedSource["data"]);
}
return new NotificationOptions($$parsedSource as Partial<NotificationOptions>);
}
}
// Private type creation functions
const $$createType0 = NotificationAction.createFrom;
const $$createType1 = $Create.Array($$createType0);
const $$createType2 = $Create.Map($Create.Any, $Create.Any);

View File

@@ -0,0 +1,110 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Service represents the notifications service
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
export function CheckNotificationAuthorization(): Promise<boolean> & { cancel(): void } {
let $resultPromise = $Call.ByID(2216952893) as any;
return $resultPromise;
}
/**
* OnNotificationResponse registers a callback function that will be called when
* a notification response is received from the user.
*/
export function OnNotificationResponse(callback: any): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1642697808, callback) as any;
return $resultPromise;
}
export function RegisterNotificationCategory(category: $models.NotificationCategory): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2917562919, category) as any;
return $resultPromise;
}
export function RemoveAllDeliveredNotifications(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3956282340) as any;
return $resultPromise;
}
export function RemoveAllPendingNotifications(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(108821341) as any;
return $resultPromise;
}
export function RemoveDeliveredNotification(identifier: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(975691940, identifier) as any;
return $resultPromise;
}
export function RemoveNotification(identifier: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3966653866, identifier) as any;
return $resultPromise;
}
export function RemoveNotificationCategory(categoryID: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2032615554, categoryID) as any;
return $resultPromise;
}
export function RemovePendingNotification(identifier: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3729049703, identifier) as any;
return $resultPromise;
}
/**
* Public methods that delegate to the implementation.
*/
export function RequestNotificationAuthorization(): Promise<boolean> & { cancel(): void } {
let $resultPromise = $Call.ByID(3933442950) as any;
return $resultPromise;
}
export function SendNotification(options: $models.NotificationOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3968228732, options) as any;
return $resultPromise;
}
export function SendNotificationWithActions(options: $models.NotificationOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1886542847, options) as any;
return $resultPromise;
}
/**
* ServiceName returns the name of the service.
*/
export function ServiceName(): Promise<string> & { cancel(): void } {
let $resultPromise = $Call.ByID(2704532675) as any;
return $resultPromise;
}
/**
* ServiceShutdown is called when the service is unloaded.
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2550195434) as any;
return $resultPromise;
}
/**
* ServiceStartup is called when the service is loaded.
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(4047820929, options) as any;
return $resultPromise;
}

View File

@@ -0,0 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export * from "./models.js";

View File

@@ -0,0 +1,46 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Create as $Create} from "@wailsio/runtime";
/**
* RGBA represents a traditional 32-bit alpha-premultiplied color, having 8
* bits for each of red, green, blue and alpha.
*
* An alpha-premultiplied color component C has been scaled by alpha (A), so
* has valid values 0 <= C <= A.
*/
export class RGBA {
"R": number;
"G": number;
"B": number;
"A": number;
/** Creates a new RGBA instance. */
constructor($$source: Partial<RGBA> = {}) {
if (!("R" in $$source)) {
this["R"] = 0;
}
if (!("G" in $$source)) {
this["G"] = 0;
}
if (!("B" in $$source)) {
this["B"] = 0;
}
if (!("A" in $$source)) {
this["A"] = 0;
}
Object.assign(this, $$source);
}
/**
* Creates a new RGBA instance from a string or object.
*/
static createFrom($$source: any = {}): RGBA {
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
return new RGBA($$parsedSource as Partial<RGBA>);
}
}

View File

@@ -50,6 +50,14 @@ export function GetSettingsPath(): Promise<string> & { cancel(): void } {
return $resultPromise; return $resultPromise;
} }
/**
* MigrateConfig 执行配置迁移
*/
export function MigrateConfig(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(434292783) as any;
return $resultPromise;
}
/** /**
* ResetConfig 强制重置所有配置为默认值 * ResetConfig 强制重置所有配置为默认值
*/ */

View File

@@ -13,6 +13,7 @@ import * as MigrationService from "./migrationservice.js";
import * as SelfUpdateService from "./selfupdateservice.js"; import * as SelfUpdateService from "./selfupdateservice.js";
import * as StartupService from "./startupservice.js"; import * as StartupService from "./startupservice.js";
import * as SystemService from "./systemservice.js"; import * as SystemService from "./systemservice.js";
import * as TestService from "./testservice.js";
import * as ThemeService from "./themeservice.js"; import * as ThemeService from "./themeservice.js";
import * as TranslationService from "./translationservice.js"; import * as TranslationService from "./translationservice.js";
import * as TrayService from "./trayservice.js"; import * as TrayService from "./trayservice.js";
@@ -31,6 +32,7 @@ export {
SelfUpdateService, SelfUpdateService,
StartupService, StartupService,
SystemService, SystemService,
TestService,
ThemeService, ThemeService,
TranslationService, TranslationService,
TrayService, TrayService,

View File

@@ -0,0 +1,55 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* TestService 测试服务 - 仅在开发环境使用
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
/**
* ClearAll 清除所有测试状态
*/
export function ClearAll(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2179720854) as any;
return $resultPromise;
}
/**
* ServiceStartup 服务启动时调用
*/
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(617408198, options) as any;
return $resultPromise;
}
/**
* TestBadge 测试Badge功能
*/
export function TestBadge(text: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(4242952145, text) as any;
return $resultPromise;
}
/**
* TestNotification 测试通知功能
*/
export function TestNotification(title: string, subtitle: string, body: string): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1697553289, title, subtitle, body) as any;
return $resultPromise;
}
/**
* TestUpdateNotification 测试更新通知
*/
export function TestUpdateNotification(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3091730060) as any;
return $resultPromise;
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,13 +12,13 @@
"lint:fix": "eslint --fix" "lint:fix": "eslint --fix"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.6", "@codemirror/autocomplete": "^6.18.7",
"@codemirror/commands": "^6.8.1", "@codemirror/commands": "^6.8.1",
"@codemirror/lang-angular": "^0.1.4", "@codemirror/lang-angular": "^0.1.4",
"@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1", "@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.10",
"@codemirror/lang-java": "^6.0.2", "@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
@@ -33,7 +33,6 @@
"@codemirror/lang-sql": "^6.9.1", "@codemirror/lang-sql": "^6.9.1",
"@codemirror/lang-vue": "^0.1.3", "@codemirror/lang-vue": "^0.1.3",
"@codemirror/lang-wast": "^6.0.2", "@codemirror/lang-wast": "^6.0.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.3", "@codemirror/language": "^6.11.3",
"@codemirror/language-data": "^6.5.1", "@codemirror/language-data": "^6.5.1",
@@ -41,7 +40,7 @@
"@codemirror/lint": "^6.8.5", "@codemirror/lint": "^6.8.5",
"@codemirror/search": "^6.5.11", "@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.1", "@codemirror/view": "^6.38.2",
"@lezer/highlight": "^1.2.1", "@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2", "@lezer/lr": "^1.4.2",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
@@ -50,31 +49,37 @@
"colors-named-hex": "^1.0.2", "colors-named-hex": "^1.0.2",
"franc-min": "^6.2.0", "franc-min": "^6.2.0",
"hsl-matcher": "^1.2.4", "hsl-matcher": "^1.2.4",
"jsox": "^1.2.123",
"lezer": "^0.13.5", "lezer": "^0.13.5",
"linguist-languages": "^9.0.0",
"node-sql-parser": "^5.3.12",
"php-parser": "^3.2.5",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0", "pinia-plugin-persistedstate": "^4.5.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"remarkable": "^2.0.1", "remarkable": "^2.0.1",
"sass": "^1.90.0", "sass": "^1.92.1",
"vue": "^3.5.19", "sql-formatter": "^15.6.9",
"vue-i18n": "^11.1.11", "vue": "^3.5.21",
"vue-i18n": "^11.1.12",
"vue-pick-colors": "^1.8.0", "vue-pick-colors": "^1.8.0",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.34.0", "@eslint/js": "^9.35.0",
"@lezer/generator": "^1.8.0", "@lezer/generator": "^1.8.0",
"@types/node": "^24.3.0", "@types/node": "^24.3.1",
"@types/remarkable": "^2.0.8", "@types/remarkable": "^2.0.8",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@wailsio/runtime": "latest", "@wailsio/runtime": "latest",
"eslint": "^9.34.0", "eslint": "^9.35.0",
"eslint-plugin-vue": "^10.4.0", "eslint-plugin-vue": "^10.4.0",
"globals": "^16.3.0", "globals": "^16.4.0",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"typescript-eslint": "^8.40.0", "typescript-eslint": "^8.43.0",
"unplugin-vue-components": "^29.0.0", "unplugin-vue-components": "^29.0.0",
"vite": "^7.1.3", "vite": "^7.1.5",
"vite-plugin-node-polyfills": "^0.24.0",
"vue-eslint-parser": "^10.2.0", "vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.6" "vue-tsc": "^3.0.6"
} }

BIN
frontend/public/go.wasm Normal file

Binary file not shown.

View File

@@ -0,0 +1,561 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();

View File

@@ -8,19 +8,11 @@ import KeyBindingsPage from '@/views/settings/pages/KeyBindingsPage.vue';
import UpdatesPage from '@/views/settings/pages/UpdatesPage.vue'; import UpdatesPage from '@/views/settings/pages/UpdatesPage.vue';
import ExtensionsPage from '@/views/settings/pages/ExtensionsPage.vue'; import ExtensionsPage from '@/views/settings/pages/ExtensionsPage.vue';
import BackupPage from '@/views/settings/pages/BackupPage.vue'; import BackupPage from '@/views/settings/pages/BackupPage.vue';
// 测试页面
import TestPage from '@/views/settings/pages/TestPage.vue';
const routes: RouteRecordRaw[] = [ // 基础设置子路由
{ const settingsChildren: RouteRecordRaw[] = [
path: '/',
name: 'Editor',
component: Editor
},
{
path: '/settings',
name: 'Settings',
redirect: '/settings/general',
component: Settings,
children: [
{ {
path: 'general', path: 'general',
name: 'SettingsGeneral', name: 'SettingsGeneral',
@@ -56,7 +48,29 @@ const routes: RouteRecordRaw[] = [
name: 'SettingsBackup', name: 'SettingsBackup',
component: BackupPage component: BackupPage
} }
] ];
// 仅在开发环境添加测试页面路由
if (import.meta.env.DEV) {
settingsChildren.push({
path: 'test',
name: 'SettingsTest',
component: TestPage
});
}
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Editor',
component: Editor
},
{
path: '/settings',
name: 'Settings',
redirect: '/settings/general',
component: Settings,
children: settingsChildren
} }
]; ];

View File

@@ -0,0 +1,32 @@
@echo off
rem Build script for Go Prettier Plugin WASM
rem This script compiles the Go code to WebAssembly
echo 🔨 Building Go Prettier Plugin WASM...
rem Set WASM build environment
set GOOS=js
set GOARCH=wasm
rem Build the WASM file
echo Compiling main.go to go.wasm...
go build -o go.wasm main.go
if %ERRORLEVEL% EQU 0 (
echo ✅ Build successful!
rem Show file size (Windows version)
for %%A in (go.wasm) do echo 📊 WASM file size: %%~zA bytes
rem Copy to public directory for browser access
if exist "..\..\..\..\..\public" (
copy go.wasm ..\..\..\..\..\public\go.wasm > nul
echo 📋 Copied to public directory
)
echo 🎉 Go Prettier Plugin WASM is ready!
) else (
echo ❌ Build failed!
pause
exit /b 1
)

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Build script for Go Prettier Plugin WASM
# This script compiles the Go code to WebAssembly
echo "🔨 Building Go Prettier Plugin WASM..."
# Set WASM build environment
export GOOS=js
export GOARCH=wasm
# Build the WASM file
echo "Compiling main.go to go.wasm..."
go build -o go.wasm main.go
if [ $? -eq 0 ]; then
echo "✅ Build successful!"
echo "📊 WASM file size: $(du -h go.wasm | cut -f1)"
# Copy to public directory for browser access
if [ -d "../../../../../public" ]; then
cp go.wasm ../../../../../public/go.wasm
echo "📋 Copied to public directory"
fi
echo "🎉 Go Prettier Plugin WASM is ready!"
else
echo "❌ Build failed!"
exit 1
fi

View File

@@ -0,0 +1,10 @@
import { Parser, Plugin } from "prettier";
export declare const languages: Plugin["languages"];
export declare const parsers: {
go: Parser;
};
export declare const printers: Plugin["printers"];
declare const plugin: Plugin;
export default plugin;

View File

@@ -0,0 +1,116 @@
/**
* Go Prettier Plugin for Vite + Vue3 Environment
* WebAssembly-based Go code formatter for Prettier
*/
let initializePromise = null;
// Load WASM file from public directory
const loadWasm = async () => {
try {
const response = await fetch('/go.wasm');
if (!response.ok) {
throw new Error(`Failed to load WASM file: ${response.status} ${response.statusText}`);
}
return await response.arrayBuffer();
} catch (error) {
console.error('WASM loading failed:', error);
throw error;
}
};
// Initialize Go runtime
const initGoRuntime = async () => {
if (globalThis.Go) return;
// Auto-load wasm_exec.js if not available
try {
const script = document.createElement('script');
script.src = '/wasm_exec.js';
document.head.appendChild(script);
await new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = () => reject(new Error('Failed to load wasm_exec.js'));
setTimeout(() => reject(new Error('wasm_exec.js loading timeout')), 5000);
});
if (!globalThis.Go) {
throw new Error('Go WASM runtime is not available after loading wasm_exec.js');
}
} catch (error) {
console.error('Failed to load wasm_exec.js:', error);
throw error;
}
};
const initialize = async () => {
if (initializePromise) return initializePromise;
initializePromise = (async () => {
await initGoRuntime();
const go = new globalThis.Go();
const wasmBuffer = await loadWasm();
const {instance} = await WebAssembly.instantiate(wasmBuffer, go.importObject);
// Run Go program
go.run(instance).catch(err => {
console.error('Go WASM program exit error:', err);
});
// Wait for initialization to complete
await new Promise(resolve => setTimeout(resolve, 200));
// Check if formatGo function is available
if (typeof globalThis.formatGo !== 'function') {
throw new Error('Go WASM module not properly initialized - formatGo function not available');
}
})();
return initializePromise;
};
export const languages = [
{
name: "Go",
parsers: ["go"],
extensions: [".go"],
vscodeLanguageIds: ["go"],
},
];
export const parsers = {
go: {
parse: (text) => text,
astFormat: "go-format",
locStart: (node) => 0,
locEnd: (node) => node.length,
},
};
export const printers = {
"go-format": {
print: async (path) => {
await initialize();
const text = path.getValue();
if (typeof globalThis.formatGo !== 'function') {
throw new Error('Go WASM module not properly initialized - formatGo function missing');
}
try {
return globalThis.formatGo(text);
} catch (error) {
throw new Error(`Go formatting failed: ${error.message}`);
}
},
},
};
// Default export for Prettier plugin compatibility
export default {
languages,
parsers,
printers
};

View File

@@ -0,0 +1,255 @@
//go:build js && wasm
// Package main implements a WebAssembly module that provides Go code formatting
// functionality for the Prettier plugin. This package exposes the formatGo function
// to JavaScript, enabling web-based Go code formatting with better error tolerance.
package main
import (
"bytes"
"fmt"
"go/format"
"go/parser"
"go/token"
"strings"
"syscall/js"
)
// formatGoCode attempts to format Go source code with better error tolerance
func formatGoCode(src string) (string, error) {
// Trim input but preserve leading/trailing newlines structure
trimmed := strings.TrimSpace(src)
if trimmed == "" {
return src, nil
}
// First try the standard format.Source for complete, valid code
if formatted, err := format.Source([]byte(src)); err == nil {
return string(formatted), nil
}
// Create a new file set for parsing
fset := token.NewFileSet()
// Strategy 1: Try as complete Go file
if parsed, err := parser.ParseFile(fset, "", src, parser.ParseComments); err == nil {
return formatASTNode(fset, parsed)
}
// Strategy 2: Try wrapping as package-level declarations
packageWrapped := fmt.Sprintf("package main\n\n%s", trimmed)
if parsed, err := parser.ParseFile(fset, "", packageWrapped, parser.ParseComments); err == nil {
if formatted, err := formatASTNode(fset, parsed); err == nil {
return extractPackageContent(formatted), nil
}
}
// Strategy 3: Try wrapping in main function
funcWrapped := fmt.Sprintf("package main\n\nfunc main() {\n%s\n}", indentLines(trimmed, "\t"))
if parsed, err := parser.ParseFile(fset, "", funcWrapped, parser.ParseComments); err == nil {
if formatted, err := formatASTNode(fset, parsed); err == nil {
return extractFunctionBody(formatted), nil
}
}
// Strategy 4: Try wrapping in anonymous function
anonWrapped := fmt.Sprintf("package main\n\nvar _ = func() {\n%s\n}", indentLines(trimmed, "\t"))
if parsed, err := parser.ParseFile(fset, "", anonWrapped, parser.ParseComments); err == nil {
if formatted, err := formatASTNode(fset, parsed); err == nil {
return extractFunctionBody(formatted), nil
}
}
// Strategy 5: Try line-by-line formatting for complex cases
return formatLineByLine(trimmed, fset)
}
// formatASTNode formats an AST node using the standard formatter
func formatASTNode(fset *token.FileSet, node interface{}) (string, error) {
var buf bytes.Buffer
if err := format.Node(&buf, fset, node); err != nil {
return "", err
}
return buf.String(), nil
}
// extractPackageContent extracts content after package declaration
func extractPackageContent(formatted string) string {
lines := strings.Split(formatted, "\n")
var contentLines []string
skipNext := false
for _, line := range lines {
if strings.HasPrefix(line, "package ") {
skipNext = true
continue
}
if skipNext && strings.TrimSpace(line) == "" {
skipNext = false
continue
}
if !skipNext {
contentLines = append(contentLines, line)
}
}
return strings.Join(contentLines, "\n")
}
// extractFunctionBody extracts content from within a function body
func extractFunctionBody(formatted string) string {
lines := strings.Split(formatted, "\n")
var bodyLines []string
inFunction := false
braceCount := 0
for _, line := range lines {
if strings.Contains(line, "func ") && strings.Contains(line, "{") {
inFunction = true
braceCount = 1
continue
}
if inFunction {
// Count braces to know when function ends
braceCount += strings.Count(line, "{")
braceCount -= strings.Count(line, "}")
if braceCount == 0 {
break
}
// Remove one level of indentation and add the line
if strings.HasPrefix(line, "\t") {
bodyLines = append(bodyLines, line[1:])
} else {
bodyLines = append(bodyLines, line)
}
}
}
// Remove empty lines from start and end
for len(bodyLines) > 0 && strings.TrimSpace(bodyLines[0]) == "" {
bodyLines = bodyLines[1:]
}
for len(bodyLines) > 0 && strings.TrimSpace(bodyLines[len(bodyLines)-1]) == "" {
bodyLines = bodyLines[:len(bodyLines)-1]
}
return strings.Join(bodyLines, "\n")
}
// indentLines adds indentation to each non-empty line
func indentLines(text, indent string) string {
lines := strings.Split(text, "\n")
var indentedLines []string
for _, line := range lines {
if strings.TrimSpace(line) == "" {
indentedLines = append(indentedLines, "")
} else {
indentedLines = append(indentedLines, indent+line)
}
}
return strings.Join(indentedLines, "\n")
}
// formatLineByLine attempts to format each statement individually
func formatLineByLine(src string, fset *token.FileSet) (string, error) {
lines := strings.Split(src, "\n")
var formattedLines []string
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" {
formattedLines = append(formattedLines, "")
continue
}
// Try different wrapping strategies for individual lines
attempts := []string{
fmt.Sprintf("package main\n\nfunc main() {\n\t%s\n}", trimmedLine),
fmt.Sprintf("package main\n\n%s", trimmedLine),
fmt.Sprintf("package main\n\nvar _ = %s", trimmedLine),
}
formatted := trimmedLine // fallback
for _, attempt := range attempts {
if parsed, err := parser.ParseFile(fset, "", attempt, parser.ParseComments); err == nil {
if result, err := formatASTNode(fset, parsed); err == nil {
if extracted := extractSingleStatement(result, trimmedLine); extracted != "" {
formatted = extracted
break
}
}
}
}
formattedLines = append(formattedLines, formatted)
}
return strings.Join(formattedLines, "\n"), nil
}
// extractSingleStatement tries to extract a single formatted statement
func extractSingleStatement(formatted, original string) string {
lines := strings.Split(formatted, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "package ") &&
!strings.HasPrefix(trimmed, "func ") && !strings.HasPrefix(trimmed, "var _ =") &&
trimmed != "{" && trimmed != "}" {
// Remove leading tabs/spaces to normalize indentation
return strings.TrimLeft(line, " \t")
}
}
return original
}
// formatGo is a JavaScript-callable function that formats Go source code.
// It attempts multiple strategies to format code, including handling incomplete
// or syntactically invalid code fragments.
//
// Parameters:
// - this: The JavaScript 'this' context (unused)
// - i: JavaScript arguments array where i[0] should contain the Go source code as a string
//
// Returns:
// - js.Value: The formatted Go source code as a JavaScript string value
// - If formatting fails completely, returns the original code unchanged
// - If no arguments are provided, returns js.Null() and logs an error
func formatGo(this js.Value, i []js.Value) interface{} {
if len(i) == 0 {
js.Global().Get("console").Call("error", "formatGo: missing code argument")
return js.Null()
}
code := i[0].String()
if strings.TrimSpace(code) == "" {
return js.ValueOf(code)
}
formatted, err := formatGoCode(code)
if err != nil {
js.Global().Get("console").Call("warn", "Go formatting had issues:", err.Error())
return js.ValueOf(code) // Return original code if all attempts fail
}
return js.ValueOf(formatted)
}
// main initializes the WebAssembly module and exposes the formatGo function
// to the JavaScript global scope.
func main() {
// Create a channel to keep the Go program running
c := make(chan struct{}, 0)
// Expose the formatGo function to the JavaScript global scope
js.Global().Set("formatGo", js.FuncOf(formatGo))
// Block forever
<-c
}

View File

@@ -0,0 +1,4 @@
import type { Plugin } from "prettier";
declare const plugin: Plugin;
export default plugin;

View File

@@ -0,0 +1,19 @@
export {
languages,
printers,
parsers,
options,
defaultOptions
} from './src/index.mjs';
import { languages, printers, parsers, options, defaultOptions } from './src/index.mjs';
const phpPlugin = {
languages,
printers,
parsers,
options,
defaultOptions
};
export default phpPlugin;

View File

@@ -0,0 +1,111 @@
import { printNumber, normalizeMagicMethodName } from "./util.mjs";
const ignoredProperties = new Set([
"loc",
"range",
"raw",
"comments",
"leadingComments",
"trailingComments",
"parenthesizedExpression",
"parent",
"prev",
"start",
"end",
"tokens",
"errors",
"extra",
]);
/**
* This function takes the existing ast node and a copy, by reference
* We use it for testing, so that we can compare pre-post versions of the AST,
* excluding things we don't care about (like node location, case that will be
* changed by the printer, etc.)
*/
function clean(node, newObj) {
if (node.kind === "string") {
// TODO if options are available in this method, replace with
// newObj.isDoubleQuote = !useSingleQuote(node, options);
delete newObj.isDoubleQuote;
}
if (["array", "list"].includes(node.kind)) {
// TODO if options are available in this method, assign instead of delete
delete newObj.shortForm;
}
if (node.kind === "inline") {
if (node.value.includes("___PSEUDO_INLINE_PLACEHOLDER___")) {
return null;
}
newObj.value = newObj.value.replace(/\n/g, "");
}
// continue ((2)); -> continue 2;
// continue 1; -> continue;
if ((node.kind === "continue" || node.kind === "break") && node.level) {
const { level } = newObj;
if (level.kind === "number") {
newObj.level = level.value === "1" ? null : level;
}
}
// if () {{ }} -> if () {}
if (node.kind === "block") {
if (node.children.length === 1 && node.children[0].kind === "block") {
while (newObj.children[0].kind === "block") {
newObj.children = newObj.children[0].children;
}
}
}
// Normalize numbers
if (node.kind === "number") {
newObj.value = printNumber(node.value);
}
const statements = ["foreach", "for", "if", "while", "do"];
if (statements.includes(node.kind)) {
if (node.body && node.body.kind !== "block") {
newObj.body = {
kind: "block",
children: [newObj.body],
};
} else {
newObj.body = newObj.body ? newObj.body : null;
}
if (node.alternate && node.alternate.kind !== "block") {
newObj.alternate = {
kind: "block",
children: [newObj.alternate],
};
} else {
newObj.alternate = newObj.alternate ? newObj.alternate : null;
}
}
if (node.kind === "usegroup" && typeof node.name === "string") {
newObj.name = newObj.name.replace(/^\\/, "");
}
if (node.kind === "useitem") {
newObj.name = newObj.name.replace(/^\\/, "");
}
if (node.kind === "method" && node.name.kind === "identifier") {
newObj.name.name = normalizeMagicMethodName(newObj.name.name);
}
if (node.kind === "noop") {
return null;
}
}
clean.ignoredProperties = ignoredProperties;
export default clean;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
import { doc } from "prettier";
import LINGUIST_LANGUAGES_PHP from "linguist-languages/data/PHP";
import LINGUIST_LANGUAGES_HTML_PHP from "linguist-languages/data/HTML_2b_PHP";
import parse from "./parser.mjs";
import print from "./printer.mjs";
import clean from "./clean.mjs";
import options from "./options.mjs";
import {
handleOwnLineComment,
handleEndOfLineComment,
handleRemainingComment,
getCommentChildNodes,
canAttachComment,
isBlockComment,
} from "./comments.mjs";
import { hasPragma, insertPragma } from "./pragma.mjs";
import { locStart, locEnd } from "./loc.mjs";
const { join, hardline } = doc.builders;
function createLanguage(linguistData, { extend, override }) {
const language = {};
for (const key in linguistData) {
const newKey = key === "languageId" ? "linguistLanguageId" : key;
language[newKey] = linguistData[key];
}
if (extend) {
for (const key in extend) {
language[key] = (language[key] || []).concat(extend[key]);
}
}
for (const key in override) {
language[key] = override[key];
}
return language;
}
const languages = [
createLanguage(LINGUIST_LANGUAGES_PHP, {
override: {
parsers: ["php"],
vscodeLanguageIds: ["php"],
},
}),
createLanguage(LINGUIST_LANGUAGES_HTML_PHP, {
override: {
parsers: ["php"],
vscodeLanguageIds: ["php"],
},
}),
];
const parsers = {
php: {
parse,
astFormat: "php",
locStart,
locEnd,
hasPragma,
},
};
const ignoredKeys = new Set([
"kind",
"loc",
"errors",
"extra",
"comments",
"leadingComments",
"enclosingNode",
"precedingNode",
"followingNode",
]);
function getVisitorKeys(node, nonTraversableKeys) {
return Object.keys(node).filter(
(key) => !nonTraversableKeys.has(key) && !ignoredKeys.has(key)
);
}
const printers = {
php: {
print,
getVisitorKeys,
insertPragma,
massageAstNode: clean,
getCommentChildNodes,
canAttachComment,
isBlockComment,
handleComments: {
ownLine: handleOwnLineComment,
endOfLine: handleEndOfLineComment,
remaining: handleRemainingComment,
},
willPrintOwnComments(path) {
const { node } = path;
return node && node.kind === "noop";
},
printComment(path) {
const comment = path.node;
switch (comment.kind) {
case "commentblock": {
// for now, don't touch single line block comments
if (!comment.value.includes("\n")) {
return comment.value;
}
const lines = comment.value.split("\n");
// if this is a block comment, handle indentation
if (
lines
.slice(1, lines.length - 1)
.every((line) => line.trim()[0] === "*")
) {
return join(
hardline,
lines.map(
(line, index) =>
(index > 0 ? " " : "") +
(index < lines.length - 1 ? line.trim() : line.trimLeft())
)
);
}
// otherwise we can't be sure about indentation, so just print as is
return comment.value;
}
case "commentline": {
return comment.value.trimRight();
}
/* c8 ignore next 2 */
default:
throw new Error(`Not a comment: ${JSON.stringify(comment)}`);
}
},
hasPrettierIgnore(path) {
const isSimpleIgnore = (comment) =>
comment.value.includes("prettier-ignore") &&
!comment.value.includes("prettier-ignore-start") &&
!comment.value.includes("prettier-ignore-end");
const { node, parent: parentNode } = path;
return (
(node &&
node.kind !== "classconstant" &&
node.comments &&
node.comments.length > 0 &&
node.comments.some(isSimpleIgnore)) ||
// For proper formatting, the classconstant ignore formatting should
// run on the "constant" child
(node &&
node.kind === "constant" &&
parentNode &&
parentNode.kind === "classconstant" &&
parentNode.comments &&
parentNode.comments.length > 0 &&
parentNode.comments.some(isSimpleIgnore))
);
},
},
};
const defaultOptions = {
tabWidth: 4,
};
export { languages, printers, parsers, options, defaultOptions };

View File

@@ -0,0 +1,4 @@
const loc = (prop) => (node) => node.loc?.[prop]?.offset;
export const locStart = loc("start");
export const locEnd = loc("end");

View File

@@ -0,0 +1,250 @@
import { getPrecedence, shouldFlatten, isBitwiseOperator } from "./util.mjs";
function needsParens(path, options) {
const { parent } = path;
if (!parent) {
return false;
}
const { key, node } = path;
if (
[
// No need parens for top level children of this nodes
"program",
"expressionstatement",
"namespace",
"declare",
"block",
// No need parens
"include",
"print",
"return",
"echo",
].includes(parent.kind)
) {
return false;
}
switch (node.kind) {
case "pre":
case "post":
if (parent.kind === "unary") {
return (
node.kind === "pre" &&
((node.type === "+" && parent.type === "+") ||
(node.type === "-" && parent.type === "-"))
);
}
// else fallthrough
case "unary":
switch (parent.kind) {
case "unary":
return (
node.type === parent.type &&
(node.type === "+" || node.type === "-")
);
case "propertylookup":
case "nullsafepropertylookup":
case "staticlookup":
case "offsetlookup":
case "call":
return key === "what";
case "bin":
return parent.type === "**" && key === "left";
default:
return false;
}
case "bin": {
switch (parent.kind) {
case "assign":
case "retif":
return ["and", "xor", "or"].includes(node.type);
case "silent":
case "cast":
// TODO: bug https://github.com/glayzzle/php-parser/issues/172
return node.parenthesizedExpression;
case "pre":
case "post":
case "unary":
return true;
case "call":
case "propertylookup":
case "nullsafepropertylookup":
case "staticlookup":
case "offsetlookup":
return key === "what";
case "bin": {
const po = parent.type;
const pp = getPrecedence(po);
const no = node.type;
const np = getPrecedence(no);
if (pp > np) {
return true;
}
if (po === "||" && no === "&&") {
return true;
}
if (pp === np && key === "right") {
return true;
}
if (pp === np && !shouldFlatten(po, no)) {
return true;
}
if (pp < np && no === "%") {
return po === "+" || po === "-";
}
// Add parenthesis when working with bitwise operators
// It's not stricly needed but helps with code understanding
if (isBitwiseOperator(po)) {
return true;
}
return false;
}
default:
return false;
}
}
case "propertylookup":
case "nullsafepropertylookup":
case "staticlookup": {
switch (parent.kind) {
case "call":
return key === "what" && node.parenthesizedExpression;
default:
return false;
}
}
case "clone":
case "new": {
const requiresParens =
node.kind === "clone" ||
(node.kind === "new" && options.phpVersion < 8.4);
switch (parent.kind) {
case "propertylookup":
case "nullsafepropertylookup":
case "staticlookup":
case "offsetlookup":
case "call":
return key === "what" && requiresParens;
default:
return false;
}
}
case "yield": {
switch (parent.kind) {
case "propertylookup":
case "nullsafepropertylookup":
case "staticlookup":
case "offsetlookup":
case "call":
return key === "what";
case "retif":
return key === "test";
default:
return !!(node.key || node.value);
}
}
case "assign": {
if (
parent.kind === "for" &&
(parent.init.includes(node) || parent.increment.includes(node))
) {
return false;
} else if (parent.kind === "assign") {
return false;
} else if (parent.kind === "static") {
return false;
} else if (
["if", "do", "while", "foreach", "switch"].includes(parent.kind)
) {
return false;
} else if (parent.kind === "silent") {
return false;
} else if (parent.kind === "call") {
return false;
}
return true;
}
case "retif":
switch (parent.kind) {
case "cast":
return true;
case "unary":
case "bin":
case "retif":
if (key === "test" && !parent.trueExpr) {
return false;
}
return true;
case "propertylookup":
case "nullsafepropertylookup":
case "staticlookup":
case "offsetlookup":
case "call":
return key === "what";
default:
return false;
}
case "closure":
switch (parent.kind) {
case "call":
return key === "what";
// https://github.com/prettier/plugin-php/issues/1675
case "propertylookup":
case "nullsafepropertylookup":
return true;
default:
return false;
}
case "silence":
case "cast":
// TODO: bug https://github.com/glayzzle/php-parser/issues/172
return node.parenthesizedExpression;
// else fallthrough
case "string":
case "array":
switch (parent.kind) {
case "propertylookup":
case "nullsafepropertylookup":
case "staticlookup":
case "offsetlookup":
case "call":
if (
["string", "array"].includes(node.kind) &&
parent.kind === "offsetlookup"
) {
return false;
}
return key === "what";
default:
return false;
}
case "print":
case "include":
return parent.kind === "bin";
}
return false;
}
export default needsParens;

View File

@@ -0,0 +1,69 @@
const CATEGORY_PHP = "PHP";
// prettier-ignore
const SUPPORTED_PHP_VERSIONS = [
5.0, 5.1, 5.2, 5.3, 5.4, 5.5, 5.6,
7.0, 7.1, 7.2, 7.3, 7.4,
8.0, 8.1, 8.2, 8.3, 8.4,
];
export const LATEST_SUPPORTED_PHP_VERSION = Math.max(...SUPPORTED_PHP_VERSIONS);
/**
* Resolve the PHP version to a number based on the provided options.
*/
export function resolvePhpVersion(options) {
if (!options) {
return;
}
if (options.phpVersion === "auto" || options.phpVersion === "composer") {
options.phpVersion = LATEST_SUPPORTED_PHP_VERSION;
} else {
options.phpVersion = parseFloat(options.phpVersion);
}
}
export default {
phpVersion: {
since: "0.13.0",
category: CATEGORY_PHP,
type: "choice",
default: "auto",
description: "Minimum target PHP version.",
choices: [
...SUPPORTED_PHP_VERSIONS.map((v) => ({ value: v.toFixed(1) })),
{
value: "auto",
description: `Use latest PHP Version (${LATEST_SUPPORTED_PHP_VERSION})`,
},
],
},
trailingCommaPHP: {
since: "0.0.0",
category: CATEGORY_PHP,
type: "boolean",
default: true,
description: "Print trailing commas wherever possible when multi-line.",
},
braceStyle: {
since: "0.10.0",
category: CATEGORY_PHP,
type: "choice",
default: "per-cs",
description:
"Print one space or newline for code blocks (classes and functions).",
choices: [
{ value: "psr-2", description: "(deprecated) Use per-cs" },
{ value: "per-cs", description: "Use the PER Coding Style brace style." },
{ value: "1tbs", description: "Use 1tbs brace style." },
],
},
singleQuote: {
since: "0.0.0",
category: CATEGORY_PHP,
type: "boolean",
default: false,
description: "Use single quotes instead of double quotes.",
},
};

View File

@@ -0,0 +1,67 @@
import engine from "php-parser";
import { LATEST_SUPPORTED_PHP_VERSION } from "./options.mjs";
import { resolvePhpVersion } from "./options.mjs";
function parse(text, opts) {
const inMarkdown = opts && opts.parentParser === "markdown";
if (!text && inMarkdown) {
return "";
}
resolvePhpVersion(opts);
// Todo https://github.com/glayzzle/php-parser/issues/170
text = text.replace(/\?>\n<\?/g, "?>\n___PSEUDO_INLINE_PLACEHOLDER___<?");
// initialize a new parser instance
const parser = new engine({
parser: {
extractDoc: true,
version: `${LATEST_SUPPORTED_PHP_VERSION}`,
},
ast: {
withPositions: true,
withSource: true,
},
});
const hasOpenPHPTag = text.indexOf("<?php") !== -1;
const parseAsEval = inMarkdown && !hasOpenPHPTag;
let ast;
try {
ast = parseAsEval ? parser.parseEval(text) : parser.parseCode(text);
} catch (err) {
if (err instanceof SyntaxError && "lineNumber" in err) {
err.loc = {
start: {
line: err.lineNumber,
column: err.columnNumber,
},
};
delete err.lineNumber;
delete err.columnNumber;
}
throw err;
}
ast.extra = {
parseAsEval,
};
// https://github.com/glayzzle/php-parser/issues/155
// currently inline comments include the line break at the end, we need to
// strip those out and update the end location for each comment manually
ast.comments.forEach((comment) => {
if (comment.value[comment.value.length - 1] === "\n") {
comment.value = comment.value.slice(0, -1);
comment.loc.end.offset = comment.loc.end.offset - 1;
}
});
return ast;
}
export default parse;

View File

@@ -0,0 +1,92 @@
import { memoize } from "./util.mjs";
import parse from "./parser.mjs";
const reHasPragma = /@prettier|@format/;
const getPageLevelDocBlock = memoize((text) => {
const parsed = parse(text);
const [firstChild] = parsed.children;
const [firstDocBlock] = parsed.comments.filter(
(el) => el.kind === "commentblock"
);
if (
firstChild &&
firstDocBlock &&
firstDocBlock.loc.start.line < firstChild.loc.start.line
) {
return firstDocBlock;
}
});
function hasPragma(text) {
// fast path optimization - check if the pragma shows up in the file at all
if (!reHasPragma.test(text)) {
return false;
}
const pageLevelDocBlock = getPageLevelDocBlock(text);
if (pageLevelDocBlock) {
const { value } = pageLevelDocBlock;
return reHasPragma.test(value);
}
return false;
}
function injectPragma(docblock) {
let lines = docblock.split("\n");
if (lines.length === 1) {
// normalize to multiline for simplicity
const [, line] = /\/*\*\*(.*)\*\//.exec(lines[0]);
lines = ["/**", ` * ${line.trim()}`, " */"];
}
// find the first @pragma
// if there happens to be one on the opening line, just put it on the next line.
const pragmaIndex = lines.findIndex((line) => /@\S/.test(line)) || 1;
// not found => index == -1, which conveniently will splice 1 from the end.
lines.splice(pragmaIndex, 0, " * @format");
return lines.join("\n");
}
function insertPragma(text) {
const pageLevelDocBlock = getPageLevelDocBlock(text);
if (pageLevelDocBlock) {
const {
start: { offset: startOffset },
end: { offset: endOffset },
} = pageLevelDocBlock.loc;
const before = text.substring(0, startOffset);
const after = text.substring(endOffset);
return `${before}${injectPragma(pageLevelDocBlock.value, text)}${after}`;
}
const openTag = "<?php";
if (!text.startsWith(openTag)) {
// bail out
return text;
}
const splitAt = openTag.length;
const phpTag = text.substring(0, splitAt);
const after = text.substring(splitAt);
return `${phpTag}
/**
* @format
*/
${after}`;
}
export { hasPragma, insertPragma };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,743 @@
import { util as prettierUtil } from "prettier";
import { locStart } from "./loc.mjs";
const { hasNewline, skipEverythingButNewLine, skipNewline } = prettierUtil;
function printNumber(rawNumber) {
return (
rawNumber
.toLowerCase()
// Remove unnecessary plus and zeroes from scientific notation.
.replace(/^([+-]?[\d.]+e)(?:\+|(-))?0*(\d)/, "$1$2$3")
// Remove unnecessary scientific notation (1e0).
.replace(/^([+-]?[\d.]+)e[+-]?0+$/, "$1")
// Make sure numbers always start with a digit.
.replace(/^([+-])?\./, "$10.")
// Remove extraneous trailing decimal zeroes.
.replace(/(\.\d+?)0+(?=e|$)/, "$1")
// Remove unnecessary .e notation
.replace(/\.(?=e)/, "")
);
}
// http://php.net/manual/en/language.operators.precedence.php
const PRECEDENCE = new Map(
[
["or"],
["xor"],
["and"],
[
"=",
"+=",
"-=",
"*=",
"**=",
"/=",
".=",
"%=",
"&=",
"|=",
"^=",
"<<=",
">>=",
],
["??"],
["||"],
["&&"],
["|"],
["^"],
["&"],
["==", "===", "!=", "!==", "<>", "<=>"],
["<", ">", "<=", ">="],
[">>", "<<"],
["+", "-", "."],
["*", "/", "%"],
["!"],
["instanceof"],
["++", "--", "~"],
["**"],
].flatMap((operators, index) =>
operators.map((operator) => [operator, index])
)
);
function getPrecedence(operator) {
return PRECEDENCE.get(operator);
}
const equalityOperators = ["==", "!=", "===", "!==", "<>", "<=>"];
const multiplicativeOperators = ["*", "/", "%"];
const bitshiftOperators = [">>", "<<"];
function isBitwiseOperator(operator) {
return (
!!bitshiftOperators[operator] ||
operator === "|" ||
operator === "^" ||
operator === "&"
);
}
function shouldFlatten(parentOp, nodeOp) {
if (getPrecedence(nodeOp) !== getPrecedence(parentOp)) {
return false;
}
// ** is right-associative
// x ** y ** z --> x ** (y ** z)
if (parentOp === "**") {
return false;
}
// x == y == z --> (x == y) == z
if (
equalityOperators.includes(parentOp) &&
equalityOperators.includes(nodeOp)
) {
return false;
}
// x * y % z --> (x * y) % z
if (
(nodeOp === "%" && multiplicativeOperators.includes(parentOp)) ||
(parentOp === "%" && multiplicativeOperators.includes(nodeOp))
) {
return false;
}
// x * y / z --> (x * y) / z
// x / y * z --> (x / y) * z
if (
nodeOp !== parentOp &&
multiplicativeOperators.includes(nodeOp) &&
multiplicativeOperators.includes(parentOp)
) {
return false;
}
// x << y << z --> (x << y) << z
if (
bitshiftOperators.includes(parentOp) &&
bitshiftOperators.includes(nodeOp)
) {
return false;
}
return true;
}
function nodeHasStatement(node) {
return [
"block",
"program",
"namespace",
"class",
"enum",
"interface",
"trait",
"traituse",
"declare",
].includes(node.kind);
}
function getBodyFirstChild({ body }) {
if (!body) {
return null;
}
if (body.kind === "block") {
body = body.children;
}
return body[0];
}
function getNodeListProperty(node) {
const body = node.children || node.body || node.adaptations;
return Array.isArray(body) ? body : null;
}
function getLast(arr) {
if (arr.length > 0) {
return arr[arr.length - 1];
}
return null;
}
function getPenultimate(arr) {
if (arr.length > 1) {
return arr[arr.length - 2];
}
return null;
}
function isFirstChildrenInlineNode(path) {
const { node } = path;
if (node.kind === "program") {
const children = getNodeListProperty(node);
if (!children || children.length === 0) {
return false;
}
return children[0].kind === "inline";
}
if (node.kind === "switch") {
if (!node.body) {
return false;
}
const children = getNodeListProperty(node.body);
if (children.length === 0) {
return false;
}
const [firstCase] = children;
if (!firstCase.body) {
return false;
}
const firstCaseChildren = getNodeListProperty(firstCase.body);
if (firstCaseChildren.length === 0) {
return false;
}
return firstCaseChildren[0].kind === "inline";
}
const firstChild = getBodyFirstChild(node);
if (!firstChild) {
return false;
}
return firstChild.kind === "inline";
}
function isDocNode(node) {
return (
node.kind === "nowdoc" ||
(node.kind === "encapsed" && node.type === "heredoc")
);
}
/**
* Heredoc/Nowdoc nodes need a trailing linebreak if they
* appear as function arguments or array elements
*/
function docShouldHaveTrailingNewline(path, recurse = 0) {
const node = path.getNode(recurse);
const parent = path.getNode(recurse + 1);
const parentParent = path.getNode(recurse + 2);
if (!parent) {
return false;
}
if (
(parentParent &&
["call", "new", "echo"].includes(parentParent.kind) &&
!["call", "array"].includes(parent.kind)) ||
parent.kind === "parameter"
) {
const lastIndex = parentParent.arguments.length - 1;
const index = parentParent.arguments.indexOf(parent);
return index !== lastIndex;
}
if (parentParent && parentParent.kind === "for") {
const initIndex = parentParent.init.indexOf(parent);
if (initIndex !== -1) {
return initIndex !== parentParent.init.length - 1;
}
const testIndex = parentParent.test.indexOf(parent);
if (testIndex !== -1) {
return testIndex !== parentParent.test.length - 1;
}
const incrementIndex = parentParent.increment.indexOf(parent);
if (incrementIndex !== -1) {
return incrementIndex !== parentParent.increment.length - 1;
}
}
if (parent.kind === "bin") {
return (
parent.left === node || docShouldHaveTrailingNewline(path, recurse + 1)
);
}
if (parent.kind === "case" && parent.test === node) {
return true;
}
if (parent.kind === "staticvariable") {
const lastIndex = parentParent.variables.length - 1;
const index = parentParent.variables.indexOf(parent);
return index !== lastIndex;
}
if (parent.kind === "entry") {
if (parent.key === node) {
return true;
}
const lastIndex = parentParent.items.length - 1;
const index = parentParent.items.indexOf(parent);
return index !== lastIndex;
}
if (["call", "new"].includes(parent.kind)) {
const lastIndex = parent.arguments.length - 1;
const index = parent.arguments.indexOf(node);
return index !== lastIndex;
}
if (parent.kind === "echo") {
const lastIndex = parent.expressions.length - 1;
const index = parent.expressions.indexOf(node);
return index !== lastIndex;
}
if (parent.kind === "array") {
const lastIndex = parent.items.length - 1;
const index = parent.items.indexOf(node);
return index !== lastIndex;
}
if (parent.kind === "retif") {
return docShouldHaveTrailingNewline(path, recurse + 1);
}
return false;
}
function lineShouldEndWithSemicolon(path) {
const { node, parent: parentNode } = path;
if (!parentNode) {
return false;
}
// for single line control structures written in a shortform (ie without a block),
// we need to make sure the single body node gets a semicolon
if (
["for", "foreach", "while", "do", "if", "switch"].includes(
parentNode.kind
) &&
node.kind !== "block" &&
node.kind !== "if" &&
(parentNode.body === node || parentNode.alternate === node)
) {
return true;
}
if (!nodeHasStatement(parentNode)) {
return false;
}
if (node.kind === "echo" && node.shortForm) {
return false;
}
if (node.kind === "traituse") {
return !node.adaptations;
}
if (node.kind === "method" && node.isAbstract) {
return true;
}
if (node.kind === "method") {
const { parent } = path;
if (parent && parent.kind === "interface") {
return true;
}
}
return [
"expressionstatement",
"do",
"usegroup",
"classconstant",
"propertystatement",
"traitprecedence",
"traitalias",
"goto",
"constantstatement",
"enumcase",
"global",
"static",
"echo",
"unset",
"return",
"break",
"continue",
"throw",
].includes(node.kind);
}
function fileShouldEndWithHardline(path) {
const { node } = path;
const isProgramNode = node.kind === "program";
const lastNode = node.children && getLast(node.children);
if (!isProgramNode) {
return false;
}
if (lastNode && ["halt", "inline"].includes(lastNode.kind)) {
return false;
}
if (
lastNode &&
(lastNode.kind === "declare" || lastNode.kind === "namespace")
) {
const lastNestedNode =
lastNode.children.length > 0 && getLast(lastNode.children);
if (lastNestedNode && ["halt", "inline"].includes(lastNestedNode.kind)) {
return false;
}
}
return true;
}
function maybeStripLeadingSlashFromUse(name) {
const nameWithoutLeadingSlash = name.replace(/^\\/, "");
if (nameWithoutLeadingSlash.indexOf("\\") !== -1) {
return nameWithoutLeadingSlash;
}
return name;
}
function hasDanglingComments(node) {
return (
node.comments &&
node.comments.some((comment) => !comment.leading && !comment.trailing)
);
}
function isLookupNode(node) {
return (
node.kind === "propertylookup" ||
node.kind === "nullsafepropertylookup" ||
node.kind === "staticlookup" ||
node.kind === "offsetlookup"
);
}
function shouldPrintHardLineAfterStartInControlStructure(path) {
const { node } = path;
if (["try", "catch"].includes(node.kind)) {
return false;
}
return isFirstChildrenInlineNode(path);
}
function shouldPrintHardLineBeforeEndInControlStructure(path) {
const { node } = path;
if (["try", "catch"].includes(node.kind)) {
return true;
}
if (node.kind === "switch") {
const children = getNodeListProperty(node.body);
if (children.length === 0) {
return true;
}
const lastCase = getLast(children);
if (!lastCase.body) {
return true;
}
const childrenInCase = getNodeListProperty(lastCase.body);
if (childrenInCase.length === 0) {
return true;
}
return childrenInCase[0].kind !== "inline";
}
return !isFirstChildrenInlineNode(path);
}
function getAlignment(text) {
const lines = text.split("\n");
const lastLine = lines.pop();
return lastLine.length - lastLine.trimLeft().length + 1;
}
function isProgramLikeNode(node) {
return ["program", "declare", "namespace"].includes(node.kind);
}
function isReferenceLikeNode(node) {
return [
"name",
"parentreference",
"selfreference",
"staticreference",
].includes(node.kind);
}
// Return `logical` value for `bin` node containing `||` or `&&` type otherwise return kind of node.
// Require for grouping logical and binary nodes in right way.
function getNodeKindIncludingLogical(node) {
if (node.kind === "bin" && ["||", "&&"].includes(node.type)) {
return "logical";
}
return node.kind;
}
/**
* Check if string can safely be converted from double to single quotes and vice-versa, i.e.
*
* - no embedded variables ("foo $bar")
* - no linebreaks
* - no special characters like \n, \t, ...
* - no octal/hex/unicode characters
*
* See https://php.net/manual/en/language.types.string.php#language.types.string.syntax.double
*/
function useDoubleQuote(node, options) {
if (node.isDoubleQuote === options.singleQuote) {
// We have a double quote and the user passed singleQuote:true, or the other way around.
const rawValue = node.raw.slice(node.raw[0] === "b" ? 2 : 1, -1);
const isComplex = rawValue.match(
/\\([$nrtfve]|[xX][0-9a-fA-F]{1,2}|[0-7]{1,3}|u{([0-9a-fA-F]+)})|\r?\n|'|"|\$/
);
return node.isDoubleQuote ? isComplex : !isComplex;
}
return node.isDoubleQuote;
}
function hasEmptyBody(path, name = "body") {
const { node } = path;
return (
node[name] &&
node[name].children &&
node[name].children.length === 0 &&
(!node[name].comments || node[name].comments.length === 0)
);
}
function isNextLineEmptyAfterNamespace(text, node) {
let idx = locStart(node);
idx = skipEverythingButNewLine(text, idx);
idx = skipNewline(text, idx);
return hasNewline(text, idx);
}
function shouldPrintHardlineBeforeTrailingComma(lastElem) {
if (
lastElem.kind === "nowdoc" ||
(lastElem.kind === "encapsed" && lastElem.type === "heredoc")
) {
return true;
}
if (
lastElem.kind === "entry" &&
(lastElem.value.kind === "nowdoc" ||
(lastElem.value.kind === "encapsed" && lastElem.value.type === "heredoc"))
) {
return true;
}
return false;
}
function getAncestorCounter(path, typeOrTypes) {
const types = [].concat(typeOrTypes);
let counter = -1;
let ancestorNode;
while ((ancestorNode = path.getParentNode(++counter))) {
if (types.indexOf(ancestorNode.kind) !== -1) {
return counter;
}
}
return -1;
}
function getAncestorNode(path, typeOrTypes) {
const counter = getAncestorCounter(path, typeOrTypes);
return counter === -1 ? null : path.getParentNode(counter);
}
const magicMethods = [
"__construct",
"__destruct",
"__call",
"__callStatic",
"__get",
"__set",
"__isset",
"__unset",
"__sleep",
"__wakeup",
"__toString",
"__invoke",
"__set_state",
"__clone",
"__debugInfo",
];
const magicMethodsMap = new Map(
magicMethods.map((name) => [name.toLowerCase(), name])
);
function normalizeMagicMethodName(name) {
const loweredName = name.toLowerCase();
if (magicMethodsMap.has(loweredName)) {
return magicMethodsMap.get(loweredName);
}
return name;
}
/**
* @param {string[]} kindsArray
* @returns {(node: Node | Comment) => Boolean}
*/
function createTypeCheckFunction(kindsArray) {
const kinds = new Set(kindsArray);
return (node) => kinds.has(node?.kind);
}
const isSingleWordType = createTypeCheckFunction([
"variadicplaceholder",
"namedargument",
"nullkeyword",
"identifier",
"parameter",
"variable",
"variadic",
"boolean",
"literal",
"number",
"string",
"clone",
"cast",
]);
const isArrayExpression = createTypeCheckFunction(["array"]);
const isCallLikeExpression = createTypeCheckFunction([
"nullsafepropertylookup",
"propertylookup",
"staticlookup",
"offsetlookup",
"call",
"new",
]);
const isArrowFuncExpression = createTypeCheckFunction(["arrowfunc"]);
function getChainParts(node, prev = []) {
const parts = prev;
if (isCallLikeExpression(node)) {
parts.push(node);
}
if (!node.what) {
return parts;
}
return getChainParts(node.what, parts);
}
function isSimpleCallArgument(node, depth = 2) {
if (depth <= 0) {
return false;
}
const isChildSimple = (child) => isSimpleCallArgument(child, depth - 1);
if (isSingleWordType(node)) {
return true;
}
if (isArrayExpression(node)) {
return node.items.every((x) => x === null || isChildSimple(x));
}
if (isCallLikeExpression(node)) {
const parts = getChainParts(node);
parts.unshift();
return (
parts.length <= depth &&
parts.every((node) =>
isLookupNode(node)
? isChildSimple(node.offset)
: node.arguments.every(isChildSimple)
)
);
}
if (isArrowFuncExpression(node)) {
return (
node.arguments.length <= depth && node.arguments.every(isChildSimple)
);
}
return false;
}
function memoize(fn) {
const cache = new Map();
return (key) => {
if (!cache.has(key)) {
cache.set(key, fn(key));
}
return cache.get(key);
};
}
export {
printNumber,
getPrecedence,
isBitwiseOperator,
shouldFlatten,
nodeHasStatement,
getLast,
getPenultimate,
getBodyFirstChild,
lineShouldEndWithSemicolon,
fileShouldEndWithHardline,
maybeStripLeadingSlashFromUse,
hasDanglingComments,
docShouldHaveTrailingNewline,
isLookupNode,
isFirstChildrenInlineNode,
shouldPrintHardLineAfterStartInControlStructure,
shouldPrintHardLineBeforeEndInControlStructure,
getAlignment,
isProgramLikeNode,
isReferenceLikeNode,
getNodeKindIncludingLogical,
useDoubleQuote,
hasEmptyBody,
isNextLineEmptyAfterNamespace,
shouldPrintHardlineBeforeTrailingComma,
isDocNode,
getAncestorNode,
normalizeMagicMethodName,
isSimpleCallArgument,
memoize,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
// SQL语言定义
export const languages = [
{
name: "SQL",
parsers: ["sql"],
extensions: [
".sql",
".ddl",
".dml",
".hql",
".psql",
".plsql",
".mysql",
".mssql",
".pgsql",
".sqlite",
".bigquery",
".snowflake",
".redshift",
".db2",
".n1ql",
".cql"
],
filenames: [
"*.sql",
"*.ddl",
"*.dml"
]
}
];

View File

@@ -0,0 +1,40 @@
import { AST, Option } from 'node-sql-parser';
import { Options, ParserOptions, Plugin } from 'prettier';
import {
FormatOptions,
FormatOptionsWithLanguage,
} from 'sql-formatter';
export type SqlBaseOptions = Option &
Partial<
| (FormatOptions & { dialect: string })
| (FormatOptionsWithLanguage & { dialect?: never })
> & {
formatter?: 'sql-formatter' | 'node-sql-parser' | 'sql-cst';
params?: string;
paramTypes?: string;
autoDetectDialect?: boolean;
};
export type SqlOptions = ParserOptions<AST> & SqlBaseOptions;
export type SqlFormatOptions = Options & SqlBaseOptions;
export declare const languages: Plugin["languages"];
export declare const parsers: {
sql: {
parse(text: string, options?: SqlOptions): AST | string;
astFormat: 'sql';
locStart(): number;
locEnd(): number;
};
};
export declare const printers: Plugin["printers"];
export declare const options: Plugin["options"];
export declare function detectDialect(sql: string): string;
declare const SqlPlugin: Plugin<AST | string>;
export default SqlPlugin;

View File

@@ -0,0 +1,459 @@
import { JSOX } from 'jsox';
import nodeSqlParser from 'node-sql-parser';
import {
format,
formatDialect,
} from 'sql-formatter';
import { detectDialect } from './detect.mjs';
import { languages } from './languages.js';
const parser = new nodeSqlParser.Parser();
const SQL_FORMATTER = 'sql-formatter';
const NODE_SQL_PARSER = 'node-sql-parser';
// Parsers
export const parsers = {
sql: {
parse(text, options = {}) {
const { formatter, type, database } = options;
return formatter === SQL_FORMATTER
? text
: parser.astify(text, { type, database });
},
astFormat: 'sql',
locStart: () => -1,
locEnd: () => -1,
},
};
// Printers
export const printers = {
sql: {
print(path, options = {}) {
const value = path.node;
const {
formatter = SQL_FORMATTER,
type,
database,
dialect,
language,
params,
paramTypes,
autoDetectDialect = true,
...formatOptions
} = options;
let formatted;
if (typeof value === 'string') {
// 准备sql-formatter选项
const sqlFormatterOptions = {
...formatOptions,
params:
params == null
? undefined
: JSOX.parse(params),
paramTypes:
paramTypes == null
? undefined
: JSOX.parse(paramTypes),
};
let finalLanguage = language;
let finalDialect = dialect;
if (autoDetectDialect && !language && !dialect) {
const detectedDialect = detectDialect(value);
finalLanguage = detectedDialect;
}
// 使用适当的格式化方法
if (finalDialect != null) {
// 使用formatDialect方法
formatted = formatDialect(value, {
...sqlFormatterOptions,
dialect: JSOX.parse(finalDialect),
});
} else {
// 使用format方法
formatted = format(value, {
...sqlFormatterOptions,
language: finalLanguage,
});
}
} else {
// 使用node-sql-parser进行格式化
formatted = parser.sqlify(value, { type, database });
}
return formatted + '\n';
},
},
};
// 插件选项
export const options = {
formatter: {
category: 'Config',
type: 'choice',
default: SQL_FORMATTER,
description: 'Choose which formatter to be used',
choices: [
{
value: SQL_FORMATTER,
description: 'use `sql-formatter` as formatter',
},
{
value: NODE_SQL_PARSER,
description: 'use `node-sql-parser` as formatter',
},
],
},
autoDetectDialect: {
category: 'Config',
type: 'boolean',
default: true,
description: 'Automatically detect SQL dialect if language/dialect is not specified',
},
dialect: {
category: 'Config',
type: 'string',
description: 'SQL dialect for `sql-formatter` formatDialect()',
},
language: {
category: 'Config',
type: 'choice',
default: 'sql',
description: 'SQL dialect for `sql-formatter` format()',
choices: [
{
value: 'sql',
description: 'Standard SQL: https://en.wikipedia.org/wiki/SQL:2011',
},
{
value: 'bigquery',
description:
'Google Standard SQL (Bigquery): https://cloud.google.com/bigquery',
},
{
value: 'db2',
description: 'IBM DB2: https://www.ibm.com/products/db2',
},
{
value: 'db2i',
description:
'IBM DB2i (experimental): https://www.ibm.com/docs/en/i/7.5?topic=overview-db2-i',
},
{
value: 'hive',
description: 'Apache Hive: https://hive.apache.org',
},
{
value: 'mariadb',
description: 'MariaDB: https://mariadb.com',
},
{
value: 'mysql',
description: 'MySQL: https://www.mysql.com',
},
{
value: 'n1ql',
description:
'Couchbase N1QL: https://www.couchbase.com/products/n1ql',
},
{
value: 'plsql',
description:
'Oracle PL/SQL: https://www.oracle.com/database/technologies/appdev/plsql.html',
},
{
value: 'postgresql',
description: 'PostgreSQL: https://www.postgresql.org',
},
{
value: 'redshift',
description:
'Amazon Redshift: https://docs.aws.amazon.com/redshift/latest/dg/cm_chap_SQLCommandRef.html',
},
{
value: 'singlestoredb',
description:
'SingleStoreDB: https://docs.singlestore.com/db/v7.8/en/introduction/singlestore-documentation.html',
},
{
value: 'snowflake',
description: 'Snowflake: https://docs.snowflake.com',
},
{
value: 'spark',
description: 'Spark: https://spark.apache.org',
},
{
value: 'sqlite',
description: 'SQLite: https://www.sqlite.org',
},
{
value: 'transactsql',
description:
'SQL Server Transact-SQL: https://docs.microsoft.com/en-us/sql/sql-server/',
},
{
value: 'tsql',
description:
'SQL Server Transact-SQL: https://docs.microsoft.com/en-us/sql/sql-server/',
},
{
value: 'trino',
description: 'Trino: https://trino.io',
},
],
},
keywordCase: {
category: 'Output',
type: 'choice',
default: 'preserve',
description:
'Converts reserved keywords to upper- or lowercase for `sql-formatter`',
choices: [
{
value: 'preserve',
description: 'preserves the original case of reserved keywords',
},
{
value: 'upper',
description: 'converts reserved keywords to uppercase',
},
{
value: 'lower',
description: 'converts reserved keywords to lowercase',
},
],
},
dataTypeCase: {
category: 'Output',
type: 'choice',
default: 'preserve',
description:
'Converts data types to upper- or lowercase for `sql-formatter`',
choices: [
{
value: 'preserve',
description: 'preserves the original case of data types',
},
{
value: 'upper',
description: 'converts data types to uppercase',
},
{
value: 'lower',
description: 'converts data types to lowercase',
},
],
},
functionCase: {
category: 'Output',
type: 'choice',
default: 'preserve',
description:
'Converts functions to upper- or lowercase for `sql-formatter`',
choices: [
{
value: 'preserve',
description: 'preserves the original case of functions',
},
{
value: 'upper',
description: 'converts functions to uppercase',
},
{
value: 'lower',
description: 'converts functions to lowercase',
},
],
},
identifierCase: {
category: 'Output',
type: 'choice',
default: 'preserve',
description:
'Converts identifiers to upper- or lowercase for `sql-formatter`. Only unquoted identifiers are converted. (experimental)',
choices: [
{
value: 'preserve',
description: 'preserves the original case of identifiers',
},
{
value: 'upper',
description: 'converts identifiers to uppercase',
},
{
value: 'lower',
description: 'converts identifiers to lowercase',
},
],
},
uppercase: {
category: 'Output',
type: 'boolean',
deprecated: '0.7.0',
description: 'Use `keywordCase` option instead',
},
indentStyle: {
category: 'Format',
type: 'choice',
default: 'standard',
description: `Switches between different indentation styles for \`sql-formatter\`.
Caveats of using \`"tabularLeft"\` and \`"tabularRight"\`:
- \`tabWidth\` option is ignored. Indentation will always be 10 spaces, regardless of what is specified by \`tabWidth\``,
choices: [
{
value: 'standard',
description:
'indents code by the amount specified by `tabWidth` option',
},
{
value: 'tabularLeft',
description:
'indents in tabular style with 10 spaces, aligning keywords to left',
},
{
value: 'tabularRight',
description:
'indents in tabular style with 10 spaces, aligning keywords to right',
},
],
},
logicalOperatorNewline: {
category: 'Format',
type: 'choice',
default: 'before',
description:
'Decides newline placement before or after logical operators (AND, OR, XOR)',
choices: [
{
value: 'before',
description: 'adds newline before the operator',
},
{
value: 'after',
description: 'adds newline after the operator',
},
],
},
expressionWidth: {
category: 'Format',
type: 'int',
default: 50,
description:
'Determines maximum length of parenthesized expressions for `sql-formatter`',
},
linesBetweenQueries: {
category: 'Format',
type: 'int',
default: 1,
description:
'Decides how many empty lines to leave between SQL statements for `sql-formatter`',
},
denseOperators: {
category: 'Format',
type: 'boolean',
default: false,
description:
'Decides whitespace around operators for `sql-formatter`. Does not apply to logical operators (AND, OR, XOR).',
},
newlineBeforeSemicolon: {
category: 'Format',
type: 'boolean',
default: false,
description:
'Whether to place query separator (`;`) on a separate line for `sql-formatter`',
},
params: {
category: 'Format',
type: 'string',
description:
'Specifies `JSOX` **stringified** parameter values to fill in for placeholders inside SQL for `sql-formatter`. This option is designed to be used through API (though nothing really prevents usage from command line).',
},
paramTypes: {
category: 'Config',
type: 'string',
description:
'Specifies `JSOX` **stringified** parameter types to support when parsing SQL prepared statements for `sql-formatter`.',
},
type: {
category: 'Config',
type: 'choice',
default: 'table',
description: 'Check the SQL with Authority List for `node-sql-parser`',
choices: [
{
value: 'table',
description: '`table` mode',
},
{
value: 'column',
description: '`column` mode',
},
],
},
database: {
category: 'Config',
type: 'choice',
default: 'mysql',
description: 'SQL dialect for `node-sql-parser`',
choices: [
{
value: 'bigquery',
description: 'BigQuery: https://cloud.google.com/bigquery',
},
{
value: 'db2',
description: 'IBM DB2: https://www.ibm.com/analytics/db2',
},
{
value: 'hive',
description: 'Hive: https://hive.apache.org',
},
{
value: 'mariadb',
description: 'MariaDB: https://mariadb.com',
},
{
value: 'mysql',
description: 'MySQL: https://www.mysql.com',
},
{
value: 'postgresql',
description: 'PostgreSQL: https://www.postgresql.org',
},
{
value: 'transactsql',
description:
'TransactSQL: https://docs.microsoft.com/en-us/sql/t-sql',
},
{
value: 'flinksql',
description:
'FlinkSQL: https://ci.apache.org/projects/flink/flink-docs-stable',
},
{
value: 'snowflake',
description: 'Snowflake (alpha): https://docs.snowflake.com',
},
],
},
};
const SqlPlugin = {
languages,
parsers,
printers,
options,
};
export { languages };
export default SqlPlugin;

View File

@@ -2,35 +2,33 @@
* 语言映射和解析器配置 * 语言映射和解析器配置
*/ */
import { jsonLanguage } from "@codemirror/lang-json"; import {jsonLanguage} from "@codemirror/lang-json";
import { pythonLanguage } from "@codemirror/lang-python"; import {pythonLanguage} from "@codemirror/lang-python";
import { javascriptLanguage, typescriptLanguage } from "@codemirror/lang-javascript"; import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript";
import { htmlLanguage } from "@codemirror/lang-html"; import {htmlLanguage} from "@codemirror/lang-html";
import { StandardSQL } from "@codemirror/lang-sql"; import {StandardSQL} from "@codemirror/lang-sql";
import { markdownLanguage } from "@codemirror/lang-markdown"; import {markdownLanguage} from "@codemirror/lang-markdown";
import { javaLanguage } from "@codemirror/lang-java"; import {javaLanguage} from "@codemirror/lang-java";
import { phpLanguage } from "@codemirror/lang-php"; import {phpLanguage} from "@codemirror/lang-php";
import { cssLanguage } from "@codemirror/lang-css"; import {cssLanguage} from "@codemirror/lang-css";
import { cppLanguage } from "@codemirror/lang-cpp"; import {cppLanguage} from "@codemirror/lang-cpp";
import { xmlLanguage } from "@codemirror/lang-xml"; import {xmlLanguage} from "@codemirror/lang-xml";
import { rustLanguage } from "@codemirror/lang-rust"; import {rustLanguage} from "@codemirror/lang-rust";
import { yamlLanguage } from "@codemirror/lang-yaml"; import {yamlLanguage} from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language"; import {StreamLanguage} from "@codemirror/language";
import { ruby } from "@codemirror/legacy-modes/mode/ruby"; import {ruby} from "@codemirror/legacy-modes/mode/ruby";
import { shell } from "@codemirror/legacy-modes/mode/shell"; import {shell} from "@codemirror/legacy-modes/mode/shell";
import { go } from "@codemirror/legacy-modes/mode/go"; import {go} from "@codemirror/legacy-modes/mode/go";
import { csharp } from "@codemirror/legacy-modes/mode/clike"; import {csharp, kotlin, scala} from "@codemirror/legacy-modes/mode/clike";
import { clojure } from "@codemirror/legacy-modes/mode/clojure"; import {clojure} from "@codemirror/legacy-modes/mode/clojure";
import { erlang } from "@codemirror/legacy-modes/mode/erlang"; import {erlang} from "@codemirror/legacy-modes/mode/erlang";
import { swift } from "@codemirror/legacy-modes/mode/swift"; import {swift} from "@codemirror/legacy-modes/mode/swift";
import { kotlin } from "@codemirror/legacy-modes/mode/clike"; import {groovy} from "@codemirror/legacy-modes/mode/groovy";
import { groovy } from "@codemirror/legacy-modes/mode/groovy"; import {powerShell} from "@codemirror/legacy-modes/mode/powershell";
import { powerShell } from "@codemirror/legacy-modes/mode/powershell"; import {toml} from "@codemirror/legacy-modes/mode/toml";
import { scala } from "@codemirror/legacy-modes/mode/clike"; import {elixir} from "codemirror-lang-elixir";
import { toml } from "@codemirror/legacy-modes/mode/toml"; import {SupportedLanguage} from '../types';
import { elixir } from "codemirror-lang-elixir";
import { SupportedLanguage } from '../types';
import typescriptPlugin from "prettier/plugins/typescript" import typescriptPlugin from "prettier/plugins/typescript"
import babelPrettierPlugin from "prettier/plugins/babel" import babelPrettierPlugin from "prettier/plugins/babel"
@@ -38,6 +36,9 @@ import htmlPrettierPlugin from "prettier/plugins/html"
import cssPrettierPlugin from "prettier/plugins/postcss" import cssPrettierPlugin from "prettier/plugins/postcss"
import markdownPrettierPlugin from "prettier/plugins/markdown" import markdownPrettierPlugin from "prettier/plugins/markdown"
import yamlPrettierPlugin from "prettier/plugins/yaml" import yamlPrettierPlugin from "prettier/plugins/yaml"
import goPrettierPlugin from "@/utils/prettier/plugins/go/go"
import sqlPrettierPlugin from "@/utils/prettier/plugins/sql/sql"
import phpPrettierPlugin from "@/utils/prettier/plugins/php"
import * as prettierPluginEstree from "prettier/plugins/estree"; import * as prettierPluginEstree from "prettier/plugins/estree";
/** /**
@@ -51,8 +52,8 @@ export class LanguageInfo {
public prettier?: { public prettier?: {
parser: string; parser: string;
plugins: any[]; plugins: any[];
}) {
} }
) {}
} }
/** /**
@@ -69,13 +70,19 @@ export const LANGUAGES: LanguageInfo[] = [
parser: "html", parser: "html",
plugins: [htmlPrettierPlugin] plugins: [htmlPrettierPlugin]
}), }),
new LanguageInfo("sql", "SQL", StandardSQL.language.parser), new LanguageInfo("sql", "SQL", StandardSQL.language.parser, {
parser: "sql",
plugins: [sqlPrettierPlugin]
}),
new LanguageInfo("md", "Markdown", markdownLanguage.parser, { new LanguageInfo("md", "Markdown", markdownLanguage.parser, {
parser: "markdown", parser: "markdown",
plugins: [markdownPrettierPlugin] plugins: [markdownPrettierPlugin]
}), }),
new LanguageInfo("java", "Java", javaLanguage.parser), new LanguageInfo("java", "Java", javaLanguage.parser),
new LanguageInfo("php", "PHP", phpLanguage.configure({top:"Program"}).parser), new LanguageInfo("php", "PHP", phpLanguage.configure({top: "Program"}).parser, {
parser: "php",
plugins: [phpPrettierPlugin]
}),
new LanguageInfo("css", "CSS", cssLanguage.parser, { new LanguageInfo("css", "CSS", cssLanguage.parser, {
parser: "css", parser: "css",
plugins: [cssPrettierPlugin] plugins: [cssPrettierPlugin]
@@ -91,7 +98,10 @@ export const LANGUAGES: LanguageInfo[] = [
plugins: [yamlPrettierPlugin] plugins: [yamlPrettierPlugin]
}), }),
new LanguageInfo("toml", "TOML", StreamLanguage.define(toml).parser), new LanguageInfo("toml", "TOML", StreamLanguage.define(toml).parser),
new LanguageInfo("go", "Go", StreamLanguage.define(go).parser), new LanguageInfo("go", "Go", StreamLanguage.define(go).parser, {
parser: "go",
plugins: [goPrettierPlugin]
}),
new LanguageInfo("clj", "Clojure", StreamLanguage.define(clojure).parser), new LanguageInfo("clj", "Clojure", StreamLanguage.define(clojure).parser),
new LanguageInfo("ex", "Elixir", elixir().language.parser), new LanguageInfo("ex", "Elixir", elixir().language.parser),
new LanguageInfo("erl", "Erlang", StreamLanguage.define(erlang).parser), new LanguageInfo("erl", "Erlang", StreamLanguage.define(erlang).parser),

View File

@@ -19,6 +19,11 @@ const navItems = [
{ id: 'updates', icon: '🔄', route: '/settings/updates' } { id: 'updates', icon: '🔄', route: '/settings/updates' }
]; ];
// 仅在开发环境添加测试页面导航
if (import.meta.env.DEV) {
navItems.push({ id: 'test', icon: '🧪', route: '/settings/test' });
}
const activeNavItem = ref(route.path.split('/').pop() || 'general'); const activeNavItem = ref(route.path.split('/').pop() || 'general');
// 处理导航点击 // 处理导航点击
@@ -56,7 +61,7 @@ const goBackToEditor = async () => {
@click="handleNavClick(item)" @click="handleNavClick(item)"
> >
<span class="nav-icon">{{ item.icon }}</span> <span class="nav-icon">{{ item.icon }}</span>
<span class="nav-text">{{ t(`settings.${item.id}`) }}</span> <span class="nav-text">{{ item.id === 'test' ? 'Test' : t(`settings.${item.id}`) }}</span>
</div> </div>
</div> </div>
<div class="settings-footer"> <div class="settings-footer">

View File

@@ -0,0 +1,274 @@
<template>
<div class="settings-page">
<SettingSection title="Development Test Page">
<div class="dev-description">
This page is only available in development environment for testing notification and badge services.
</div>
</SettingSection>
<!-- Badge测试区域 -->
<SettingSection title="Badge Service Test">
<SettingItem title="Badge Text">
<input
v-model="badgeText"
type="text"
placeholder="Enter badge text (empty to remove)"
class="select-input"
/>
</SettingItem>
<SettingItem title="Actions">
<div class="button-group">
<button @click="testBadge" class="test-button primary">
Set Badge
</button>
<button @click="clearBadge" class="test-button">
Clear Badge
</button>
</div>
</SettingItem>
<div v-if="badgeStatus" class="test-status" :class="badgeStatus.type">
{{ badgeStatus.message }}
</div>
</SettingSection>
<!-- 通知测试区域 -->
<SettingSection title="Notification Service Test">
<SettingItem title="Title">
<input
v-model="notificationTitle"
type="text"
placeholder="Notification title"
class="select-input"
/>
</SettingItem>
<SettingItem title="Subtitle">
<input
v-model="notificationSubtitle"
type="text"
placeholder="Notification subtitle"
class="select-input"
/>
</SettingItem>
<SettingItem title="Body">
<textarea
v-model="notificationBody"
placeholder="Notification body text"
class="select-input textarea-input"
rows="3"
></textarea>
</SettingItem>
<SettingItem title="Actions">
<div class="button-group">
<button @click="testNotification" class="test-button primary">
Send Test Notification
</button>
<button @click="testUpdateNotification" class="test-button">
Test Update Notification
</button>
</div>
</SettingItem>
<div v-if="notificationStatus" class="test-status" :class="notificationStatus.type">
{{ notificationStatus.message }}
</div>
</SettingSection>
<!-- 清除所有测试状态 -->
<SettingSection title="Cleanup">
<SettingItem title="Clear All">
<button @click="clearAll" class="test-button danger">
Clear All Test States
</button>
</SettingItem>
<div v-if="clearStatus" class="test-status" :class="clearStatus.type">
{{ clearStatus.message }}
</div>
</SettingSection>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import * as TestService from '@/../bindings/voidraft/internal/services/testservice'
import SettingSection from '../components/SettingSection.vue'
import SettingItem from '../components/SettingItem.vue'
// Badge测试状态
const badgeText = ref('')
const badgeStatus = ref<{ type: string; message: string } | null>(null)
// 通知测试状态
const notificationTitle = ref('')
const notificationSubtitle = ref('')
const notificationBody = ref('')
const notificationStatus = ref<{ type: string; message: string } | null>(null)
// 清除状态
const clearStatus = ref<{ type: string; message: string } | null>(null)
// 显示状态消息的辅助函数
const showStatus = (statusRef: any, type: 'success' | 'error', message: string) => {
statusRef.value = { type, message }
setTimeout(() => {
statusRef.value = null
}, 5000)
}
// 测试Badge功能
const testBadge = async () => {
try {
await TestService.TestBadge(badgeText.value)
showStatus(badgeStatus, 'success', `Badge ${badgeText.value ? 'set to: ' + badgeText.value : 'cleared'} successfully`)
} catch (error: any) {
showStatus(badgeStatus, 'error', `Failed to set badge: ${error.message || error}`)
}
}
// 清除Badge
const clearBadge = async () => {
try {
await TestService.TestBadge('')
badgeText.value = ''
showStatus(badgeStatus, 'success', 'Badge cleared successfully')
} catch (error: any) {
showStatus(badgeStatus, 'error', `Failed to clear badge: ${error.message || error}`)
}
}
// 测试通知功能
const testNotification = async () => {
try {
await TestService.TestNotification(
notificationTitle.value,
notificationSubtitle.value,
notificationBody.value
)
showStatus(notificationStatus, 'success', 'Notification sent successfully')
} catch (error: any) {
showStatus(notificationStatus, 'error', `Failed to send notification: ${error.message || error}`)
}
}
// 测试更新通知
const testUpdateNotification = async () => {
try {
await TestService.TestUpdateNotification()
showStatus(notificationStatus, 'success', 'Update notification sent successfully (badge + notification)')
} catch (error: any) {
showStatus(notificationStatus, 'error', `Failed to send update notification: ${error.message || error}`)
}
}
// 清除所有测试状态
const clearAll = async () => {
try {
await TestService.ClearAll()
// 清空表单
badgeText.value = ''
notificationTitle.value = ''
notificationSubtitle.value = ''
notificationBody.value = ''
showStatus(clearStatus, 'success', 'All test states cleared successfully')
} catch (error: any) {
showStatus(clearStatus, 'error', `Failed to clear test states: ${error.message || error}`)
}
}
</script>
<style scoped lang="scss">
.settings-page {
padding: 20px 0 20px 0;
}
.dev-description {
color: var(--settings-text-secondary);
font-size: 12px;
line-height: 1.4;
padding: 8px 0;
}
.select-input {
padding: 6px 8px;
border: 1px solid var(--settings-input-border);
border-radius: 4px;
background-color: var(--settings-input-bg);
color: var(--settings-text);
font-size: 12px;
width: 180px;
&:focus {
outline: none;
border-color: #4a9eff;
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.2);
}
&.textarea-input {
min-height: 60px;
resize: vertical;
font-family: inherit;
line-height: 1.4;
}
}
.button-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.test-button {
padding: 6px 12px;
border: 1px solid var(--settings-border);
border-radius: 4px;
background-color: var(--settings-card-bg);
color: var(--settings-text);
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
&:hover {
background-color: var(--settings-hover);
}
&.primary {
background-color: #4a9eff;
color: white;
border-color: #4a9eff;
&:hover {
background-color: #3a8eef;
border-color: #3a8eef;
}
}
&.danger {
background-color: var(--text-danger);
color: white;
border-color: var(--text-danger);
&:hover {
opacity: 0.9;
}
}
}
.test-status {
margin-top: 12px;
padding: 8px 12px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
border: 1px solid;
&.success {
background-color: rgba(34, 197, 94, 0.1);
color: #16a34a;
border-color: rgba(34, 197, 94, 0.2);
}
&.error {
background-color: rgba(239, 68, 68, 0.1);
color: #dc2626;
border-color: rgba(239, 68, 68, 0.2);
}
}
</style>

View File

@@ -2,6 +2,7 @@ import {defineConfig, loadEnv} from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite'; import Components from 'unplugin-vue-components/vite';
import * as path from 'path'; import * as path from 'path';
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig(({mode}: { mode: string }): object => { export default defineConfig(({mode}: { mode: string }): object => {
const env: Record<string, string> = loadEnv(mode, process.cwd()); const env: Record<string, string> = loadEnv(mode, process.cwd());
@@ -15,6 +16,7 @@ export default defineConfig(({mode}: { mode: string }): object => {
}, },
plugins: [ plugins: [
vue(), vue(),
nodePolyfills(),
Components({ Components({
dts: true, dts: true,
dirs: ['src/components'], dirs: ['src/components'],

2
go.mod
View File

@@ -21,6 +21,7 @@ require (
require ( require (
code.gitea.io/sdk/gitea v0.21.0 // indirect code.gitea.io/sdk/gitea v0.21.0 // indirect
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/42wim/httpsig v1.2.3 // indirect github.com/42wim/httpsig v1.2.3 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
@@ -77,6 +78,7 @@ require (
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.41.0 // indirect golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/time v0.12.0 // indirect golang.org/x/time v0.12.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect

4
go.sum
View File

@@ -2,6 +2,8 @@ code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4=
code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA= code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
@@ -178,6 +180,8 @@ golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=

View File

@@ -53,7 +53,6 @@ func NewConfigMigrator(
// AutoMigrate automatically detects and migrates missing configuration fields // AutoMigrate automatically detects and migrates missing configuration fields
func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *koanf.Koanf) (*MigrationResult, error) { func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *koanf.Koanf) (*MigrationResult, error) {
// Load default config into temporary koanf instance
defaultKoanf := koanf.New(".") defaultKoanf := koanf.New(".")
if err := defaultKoanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil { if err := defaultKoanf.Load(structs.Provider(defaultConfig, "json"), nil); err != nil {
return nil, fmt.Errorf("failed to load default config: %w", err) return nil, fmt.Errorf("failed to load default config: %w", err)
@@ -66,23 +65,20 @@ func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *
result := &MigrationResult{ result := &MigrationResult{
MissingFields: missingFields, MissingFields: missingFields,
Migrated: len(missingFields) > 0, Migrated: len(missingFields) > 0,
Description: fmt.Sprintf("Detected %d missing configuration fields", len(missingFields)), Description: fmt.Sprintf("Found %d missing fields", len(missingFields)),
} }
// If no missing fields, return early // No migration needed
if !result.Migrated { if !result.Migrated {
cm.logger.Info("No missing configuration fields detected")
return result, nil return result, nil
} }
// Only create backup if we actually need to migrate (has missing fields) // Create backup before migration
if len(missingFields) > 0 { backupPath, err := cm.createBackup()
if backupPath, err := cm.createBackup(); err != nil { if err != nil {
cm.logger.Error("Failed to create backup", "error", err) return result, fmt.Errorf("backup creation failed: %w", err)
} else { }
result.BackupPath = backupPath result.BackupPath = backupPath
}
}
// Merge missing fields from default config // Merge missing fields from default config
if err := cm.mergeDefaultFields(currentConfig, defaultKoanf, missingFields); err != nil { if err := cm.mergeDefaultFields(currentConfig, defaultKoanf, missingFields); err != nil {
@@ -94,19 +90,25 @@ func (cm *ConfigMigrator) AutoMigrate(defaultConfig interface{}, currentConfig *
return result, fmt.Errorf("failed to save updated config: %w", err) return result, fmt.Errorf("failed to save updated config: %w", err)
} }
cm.logger.Info("Configuration migration completed successfully", "migratedFields", len(missingFields)) // Clean up backup on success
if backupPath != "" {
if err := os.Remove(backupPath); err != nil {
cm.logger.Error("Failed to remove backup", "error", err)
}
}
return result, nil return result, nil
} }
// detectMissingFields detects missing configuration fields // detectMissingFields detects missing configuration fields
func (cm *ConfigMigrator) detectMissingFields(current, defaultConfig map[string]interface{}) []string { func (cm *ConfigMigrator) detectMissingFields(current, defaultConfig map[string]interface{}) []string {
var missingFields []string var missing []string
cm.findMissingFieldsRecursive("", defaultConfig, current, &missingFields) cm.findMissing("", defaultConfig, current, &missing)
return missingFields return missing
} }
// findMissingFieldsRecursive recursively finds missing fields // findMissing recursively finds missing fields
func (cm *ConfigMigrator) findMissingFieldsRecursive(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) { func (cm *ConfigMigrator) findMissing(prefix string, defaultMap, currentMap map[string]interface{}, missing *[]string) {
for key, defaultVal := range defaultMap { for key, defaultVal := range defaultMap {
fullKey := key fullKey := key
if prefix != "" { if prefix != "" {
@@ -115,34 +117,38 @@ func (cm *ConfigMigrator) findMissingFieldsRecursive(prefix string, defaultMap,
currentVal, exists := currentMap[key] currentVal, exists := currentMap[key]
if !exists { if !exists {
// Field is completely missing // Field completely missing - add it
*missing = append(*missing, fullKey) *missing = append(*missing, fullKey)
} else { } else if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
// Check nested structures
if defaultNestedMap, ok := defaultVal.(map[string]interface{}); ok {
if currentNestedMap, ok := currentVal.(map[string]interface{}); ok { if currentNestedMap, ok := currentVal.(map[string]interface{}); ok {
cm.findMissingFieldsRecursive(fullKey, defaultNestedMap, currentNestedMap, missing) // Both are maps, recurse into them
} else { cm.findMissing(fullKey, defaultNestedMap, currentNestedMap, missing)
// Current value is not a map but default is, structure mismatch
*missing = append(*missing, fullKey)
}
} }
// Type mismatch: user has different type, don't recurse
} }
// For non-map default values, field exists, preserve user's value
} }
} }
// mergeDefaultFields merges default values for missing fields into current config // mergeDefaultFields merges default values for missing fields into current config
func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error { func (cm *ConfigMigrator) mergeDefaultFields(current, defaultConfig *koanf.Koanf, missingFields []string) error {
actuallyMerged := 0
for _, field := range missingFields { for _, field := range missingFields {
defaultValue := defaultConfig.Get(field) if defaultConfig.Exists(field) {
if defaultValue != nil { if defaultValue := defaultConfig.Get(field); defaultValue != nil {
// Always set the field, even if it causes type conflicts
// This allows configuration structure evolution during upgrades
current.Set(field, defaultValue) current.Set(field, defaultValue)
cm.logger.Debug("Merged missing field", "field", field, "value", defaultValue) actuallyMerged++
}
} }
} }
// Update last modified timestamp // Update timestamp if we actually merged fields
if actuallyMerged > 0 {
current.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339)) current.Set("metadata.lastUpdated", time.Now().Format(time.RFC3339))
}
return nil return nil
} }
@@ -165,7 +171,6 @@ func (cm *ConfigMigrator) createBackup() (string, error) {
return "", fmt.Errorf("failed to create backup: %w", err) return "", fmt.Errorf("failed to create backup: %w", err)
} }
cm.logger.Info("Configuration backup created", "path", backupPath)
return backupPath, nil return backupPath, nil
} }

View File

@@ -2,6 +2,7 @@ package services
import ( import (
"encoding/json" "encoding/json"
"fmt"
jsonparser "github.com/knadh/koanf/parsers/json" jsonparser "github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2" "github.com/knadh/koanf/v2"
@@ -158,10 +159,877 @@ func TestConfigMigrator_AutoMigrate(t *testing.T) {
assert.True(t, k.Bool("newSection.enabled"), "newSection.enabled should be added with correct value") assert.True(t, k.Bool("newSection.enabled"), "newSection.enabled should be added with correct value")
assert.Equal(t, "new section", k.String("newSection.value"), "newSection.value should be added with correct value") assert.Equal(t, "new section", k.String("newSection.value"), "newSection.value should be added with correct value")
// Check that backup was created // Check that backup was cleaned up after successful migration
backupFiles, err := filepath.Glob(filepath.Join(tempDir, "*.backup.*")) backupFiles, err := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
if err != nil { if err != nil {
t.Fatalf("Failed to list backup files: %v", err) t.Fatalf("Failed to list backup files: %v", err)
} }
assert.Equal(t, 1, len(backupFiles), "One backup file should have been created") assert.Equal(t, 0, len(backupFiles), "Backup file should have been cleaned up after successful migration")
}
// TestConfigMigrator_NoOverwrite tests that user configuration is never overwritten
func TestConfigMigrator_NoOverwrite(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_no_overwrite_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Create user config with custom values that differ from defaults
userConfig := map[string]interface{}{
"app": map[string]interface{}{
"name": "CustomAppName", // Different from default
"version": "2.0.0", // Different from default
"theme": "custom", // Different from default
},
"user": map[string]interface{}{
"name": "Custom User", // Different from default
"email": "custom@example.com", // Different from default
"settings": map[string]interface{}{
"autoSave": false, // Different from default
"language": "zh", // Different from default
},
},
}
// Create config file
configPath := filepath.Join(tempDir, "config.json")
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
os.WriteFile(configPath, jsonData, 0644)
// Create migrator and load config
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
k := koanf.New(".")
k.Load(file.Provider(configPath), jsonparser.Parser())
// Create default config with different values
defaultConfig := TestConfig{}
defaultConfig.App.Name = "DefaultApp"
defaultConfig.App.Version = "1.0.0"
defaultConfig.App.Theme = "light"
defaultConfig.User.Name = "Default User"
defaultConfig.User.Email = "default@example.com"
defaultConfig.User.Settings.AutoSave = true
defaultConfig.User.Settings.Language = "en"
defaultConfig.User.Settings.NewSetting = true // This should be added
defaultConfig.User.Settings.NewSetting2 = "value" // This should be added
defaultConfig.NewSection.Enabled = true // This should be added
defaultConfig.NewSection.Value = "new section" // This should be added
// Run migration
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
assert.True(t, result.Migrated)
// Verify user values are preserved
assert.Equal(t, "CustomAppName", k.String("app.name"), "User's app name should not be overwritten")
assert.Equal(t, "2.0.0", k.String("app.version"), "User's version should not be overwritten")
assert.Equal(t, "custom", k.String("app.theme"), "User's theme should not be overwritten")
assert.Equal(t, "Custom User", k.String("user.name"), "User's name should not be overwritten")
assert.Equal(t, "custom@example.com", k.String("user.email"), "User's email should not be overwritten")
assert.False(t, k.Bool("user.settings.autoSave"), "User's autoSave should not be overwritten")
assert.Equal(t, "zh", k.String("user.settings.language"), "User's language should not be overwritten")
// Verify missing fields were added with default values
assert.True(t, k.Bool("user.settings.newSetting"), "Missing field should be added")
assert.Equal(t, "value", k.String("user.settings.newSetting2"), "Missing field should be added")
assert.True(t, k.Bool("newSection.enabled"), "Missing section should be added")
assert.Equal(t, "new section", k.String("newSection.value"), "Missing section should be added")
}
// TestConfigMigrator_TypeMismatch tests handling of type mismatches (config structure evolution)
func TestConfigMigrator_TypeMismatch(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_type_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Create user config where some fields have different types
userConfig := map[string]interface{}{
"app": map[string]interface{}{
"name": "TestApp",
"version": "1.0.0",
"theme": "dark",
},
"user": map[string]interface{}{
"name": "Test User",
"email": "test@example.com",
"settings": "simple_string", // This is a string, but default is an object
},
"newSection": 123, // This is a number, but default is an object
}
// Create config file
configPath := filepath.Join(tempDir, "config.json")
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
os.WriteFile(configPath, jsonData, 0644)
// Create migrator and load config
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
k := koanf.New(".")
k.Load(file.Provider(configPath), jsonparser.Parser())
// Create default config
defaultConfig := TestConfig{}
defaultConfig.App.Name = "TestApp"
defaultConfig.App.Version = "1.0.0"
defaultConfig.App.Theme = "dark"
defaultConfig.User.Name = "Test User"
defaultConfig.User.Email = "test@example.com"
defaultConfig.User.Settings.AutoSave = true
defaultConfig.User.Settings.Language = "en"
defaultConfig.User.Settings.NewSetting = true
defaultConfig.User.Settings.NewSetting2 = "value"
defaultConfig.NewSection.Enabled = true
defaultConfig.NewSection.Value = "new section"
// Run migration
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
// Should detect missing fields and merge them, overriding type conflicts for config evolution
assert.True(t, result.Migrated, "Migration should be performed")
assert.Greater(t, len(result.MissingFields), 0, "Should detect missing fields with type mismatch")
// Verify that type-mismatched values are overwritten with new structure (config evolution)
// This is important for software upgrades where config structure changes
assert.NotEqual(t, "simple_string", k.String("user.settings"), "User's string should be overwritten with new structure")
assert.NotEqual(t, int64(123), k.Int64("newSection"), "User's number should be overwritten with new structure")
// Verify new structure is properly applied
assert.True(t, k.Bool("user.settings.autoSave"), "New settings structure should be applied")
assert.Equal(t, "en", k.String("user.settings.language"), "New settings structure should be applied")
assert.True(t, k.Bool("user.settings.newSetting"), "New settings structure should be applied")
assert.Equal(t, "value", k.String("user.settings.newSetting2"), "New settings structure should be applied")
assert.True(t, k.Bool("newSection.enabled"), "New section structure should be applied")
assert.Equal(t, "new section", k.String("newSection.value"), "New section structure should be applied")
}
// TestConfigMigrator_ConfigEvolution tests configuration structure evolution scenarios
func TestConfigMigrator_ConfigEvolution(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_evolution_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Simulate old config format where "features" was a simple string
// but new version expects it to be an object
oldConfig := map[string]interface{}{
"app": map[string]interface{}{
"name": "MyApp",
"version": "1.0.0",
"features": "plugin1,plugin2", // Old: simple comma-separated string
},
"database": "sqlite://data.db", // Old: simple string
}
configPath := filepath.Join(tempDir, "config.json")
jsonData, _ := json.MarshalIndent(oldConfig, "", " ")
os.WriteFile(configPath, jsonData, 0644)
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
k := koanf.New(".")
k.Load(file.Provider(configPath), jsonparser.Parser())
// New config format where "features" and "database" are objects
type NewConfig struct {
App struct {
Name string `json:"name"`
Version string `json:"version"`
Features struct {
Enabled []string `json:"enabled"`
Config string `json:"config"`
} `json:"features"`
} `json:"app"`
Database struct {
Type string `json:"type"`
URL string `json:"url"`
} `json:"database"`
}
defaultConfig := NewConfig{}
defaultConfig.App.Name = "DefaultApp" // Will be preserved from user
defaultConfig.App.Version = "2.0.0" // Will be preserved from user
defaultConfig.App.Features.Enabled = []string{"newFeature"}
defaultConfig.App.Features.Config = "default.conf"
defaultConfig.Database.Type = "postgresql"
defaultConfig.Database.URL = "postgres://localhost:5432/db"
// Run migration
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
assert.True(t, result.Migrated)
// User's non-conflicting values should be preserved
assert.Equal(t, "MyApp", k.String("app.name"), "User's app name should be preserved")
assert.Equal(t, "1.0.0", k.String("app.version"), "User's version should be preserved")
// Conflicting structure should be evolved (old string → new object)
assert.NotEqual(t, "plugin1,plugin2", k.String("app.features"), "Old features string should be replaced")
assert.Equal(t, []string{"newFeature"}, k.Strings("app.features.enabled"), "New features structure should be applied")
assert.Equal(t, "default.conf", k.String("app.features.config"), "New features structure should be applied")
assert.NotEqual(t, "sqlite://data.db", k.String("database"), "Old database string should be replaced")
assert.Equal(t, "postgresql", k.String("database.type"), "New database structure should be applied")
assert.Equal(t, "postgres://localhost:5432/db", k.String("database.url"), "New database structure should be applied")
t.Logf("Successfully evolved config structure, fields migrated: %d", len(result.MissingFields))
}
// TestConfigMigrator_ComplexNested tests complex nested structure migration
func TestConfigMigrator_ComplexNested(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_complex_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Complex user config with deep nesting
userConfig := map[string]interface{}{
"app": map[string]interface{}{
"name": "TestApp",
"version": "1.0.0",
"advanced": map[string]interface{}{
"logging": map[string]interface{}{
"level": "info",
"file": "/var/log/app.log",
// Missing: format, rotation
},
"performance": map[string]interface{}{
"cache": true,
// Missing: timeout, maxConnections
},
// Missing: security section
},
},
"plugins": map[string]interface{}{
"enabled": []string{"plugin1", "plugin2"},
// Missing: config section
},
// Missing: monitoring section
}
// Default config with additional nested fields
type ComplexConfig struct {
App struct {
Name string `json:"name"`
Version string `json:"version"`
Advanced struct {
Logging struct {
Level string `json:"level"`
File string `json:"file"`
Format string `json:"format"`
Rotation bool `json:"rotation"`
} `json:"logging"`
Performance struct {
Cache bool `json:"cache"`
Timeout int `json:"timeout"`
MaxConnections int `json:"maxConnections"`
} `json:"performance"`
Security struct {
Enabled bool `json:"enabled"`
TokenType string `json:"tokenType"`
ExpireTime int `json:"expireTime"`
} `json:"security"`
} `json:"advanced"`
} `json:"app"`
Plugins struct {
Enabled []string `json:"enabled"`
Config struct {
LoadOrder []string `json:"loadOrder"`
Settings map[string]string `json:"settings"`
} `json:"config"`
} `json:"plugins"`
Monitoring struct {
Enabled bool `json:"enabled"`
Endpoint string `json:"endpoint"`
Interval int `json:"interval"`
} `json:"monitoring"`
}
// Create config file
configPath := filepath.Join(tempDir, "config.json")
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
os.WriteFile(configPath, jsonData, 0644)
// Create migrator and load config
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
k := koanf.New(".")
k.Load(file.Provider(configPath), jsonparser.Parser())
// Create complete default config
defaultConfig := ComplexConfig{}
defaultConfig.App.Name = "TestApp"
defaultConfig.App.Version = "1.0.0"
defaultConfig.App.Advanced.Logging.Level = "info"
defaultConfig.App.Advanced.Logging.File = "/var/log/app.log"
defaultConfig.App.Advanced.Logging.Format = "json"
defaultConfig.App.Advanced.Logging.Rotation = true
defaultConfig.App.Advanced.Performance.Cache = true
defaultConfig.App.Advanced.Performance.Timeout = 30
defaultConfig.App.Advanced.Performance.MaxConnections = 100
defaultConfig.App.Advanced.Security.Enabled = true
defaultConfig.App.Advanced.Security.TokenType = "JWT"
defaultConfig.App.Advanced.Security.ExpireTime = 3600
defaultConfig.Plugins.Enabled = []string{"plugin1", "plugin2"}
defaultConfig.Plugins.Config.LoadOrder = []string{"plugin1", "plugin2"}
defaultConfig.Plugins.Config.Settings = map[string]string{"key": "value"}
defaultConfig.Monitoring.Enabled = true
defaultConfig.Monitoring.Endpoint = "/metrics"
defaultConfig.Monitoring.Interval = 60
// Run migration
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
assert.True(t, result.Migrated)
// Verify user values are preserved
assert.Equal(t, "info", k.String("app.advanced.logging.level"))
assert.Equal(t, "/var/log/app.log", k.String("app.advanced.logging.file"))
assert.True(t, k.Bool("app.advanced.performance.cache"))
// Verify missing fields were added
assert.Equal(t, "json", k.String("app.advanced.logging.format"))
assert.True(t, k.Bool("app.advanced.logging.rotation"))
assert.Equal(t, 30, k.Int("app.advanced.performance.timeout"))
assert.Equal(t, 100, k.Int("app.advanced.performance.maxConnections"))
assert.True(t, k.Bool("app.advanced.security.enabled"))
assert.Equal(t, "JWT", k.String("app.advanced.security.tokenType"))
assert.Equal(t, 3600, k.Int("app.advanced.security.expireTime"))
assert.Equal(t, []string{"plugin1", "plugin2"}, k.Strings("plugins.config.loadOrder"))
assert.True(t, k.Bool("monitoring.enabled"))
assert.Equal(t, "/metrics", k.String("monitoring.endpoint"))
assert.Equal(t, 60, k.Int("monitoring.interval"))
t.Logf("Detected missing fields: %v", result.MissingFields)
// Should detect multiple missing fields
assert.Greater(t, len(result.MissingFields), 5, "Should detect multiple missing fields")
}
// TestConfigMigrator_MultipleMigrations tests running migration multiple times
func TestConfigMigrator_MultipleMigrations(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_multiple_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Create initial config
configPath := createTestConfig(t, tempDir)
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
// Create default config
defaultConfig := TestConfig{}
defaultConfig.App.Name = "TestApp"
defaultConfig.App.Version = "1.0.0"
defaultConfig.App.Theme = "dark"
defaultConfig.User.Name = "Test User"
defaultConfig.User.Email = "test@example.com"
defaultConfig.User.Settings.AutoSave = true
defaultConfig.User.Settings.Language = "en"
defaultConfig.User.Settings.NewSetting = true
defaultConfig.User.Settings.NewSetting2 = "value"
defaultConfig.NewSection.Enabled = true
defaultConfig.NewSection.Value = "new section"
// First migration
k1 := koanf.New(".")
k1.Load(file.Provider(configPath), jsonparser.Parser())
result1, err := migrator.AutoMigrate(defaultConfig, k1)
assert.NoError(t, err)
assert.True(t, result1.Migrated, "First migration should be performed")
// Second migration - should detect no missing fields
k2 := koanf.New(".")
k2.Load(file.Provider(configPath), jsonparser.Parser())
result2, err := migrator.AutoMigrate(defaultConfig, k2)
assert.NoError(t, err)
assert.False(t, result2.Migrated, "Second migration should not be needed")
assert.Equal(t, 0, len(result2.MissingFields), "No fields should be missing in second migration")
}
// TestConfigMigrator_BackupHandling tests backup creation and cleanup
func TestConfigMigrator_BackupHandling(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_backup_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
configPath := createTestConfig(t, tempDir)
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
// Create default config
defaultConfig := TestConfig{}
defaultConfig.App.Name = "TestApp"
defaultConfig.App.Version = "1.0.0"
defaultConfig.App.Theme = "dark"
defaultConfig.User.Name = "Test User"
defaultConfig.User.Email = "test@example.com"
defaultConfig.User.Settings.AutoSave = true
defaultConfig.User.Settings.Language = "en"
defaultConfig.User.Settings.NewSetting = true
defaultConfig.User.Settings.NewSetting2 = "value"
defaultConfig.NewSection.Enabled = true
defaultConfig.NewSection.Value = "new section"
k := koanf.New(".")
k.Load(file.Provider(configPath), jsonparser.Parser())
// Run migration
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
assert.True(t, result.Migrated)
// Backup should be cleaned up after successful migration
backupFiles, _ := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
assert.Equal(t, 0, len(backupFiles), "Backup should be cleaned up after successful migration")
}
// TestConfigMigrator_NoMigrationNeeded tests when no migration is needed
func TestConfigMigrator_NoMigrationNeeded(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_no_migration_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Create complete config (no missing fields)
completeConfig := map[string]interface{}{
"app": map[string]interface{}{
"name": "TestApp",
"version": "1.0.0",
"theme": "dark",
},
"user": map[string]interface{}{
"name": "Test User",
"email": "test@example.com",
"settings": map[string]interface{}{
"autoSave": true,
"language": "en",
"newSetting": true,
"newSetting2": "value",
},
},
"newSection": map[string]interface{}{
"enabled": true,
"value": "new section",
},
}
configPath := filepath.Join(tempDir, "config.json")
jsonData, _ := json.MarshalIndent(completeConfig, "", " ")
os.WriteFile(configPath, jsonData, 0644)
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
k := koanf.New(".")
k.Load(file.Provider(configPath), jsonparser.Parser())
// Create matching default config
defaultConfig := TestConfig{}
defaultConfig.App.Name = "TestApp"
defaultConfig.App.Version = "1.0.0"
defaultConfig.App.Theme = "dark"
defaultConfig.User.Name = "Test User"
defaultConfig.User.Email = "test@example.com"
defaultConfig.User.Settings.AutoSave = true
defaultConfig.User.Settings.Language = "en"
defaultConfig.User.Settings.NewSetting = true
defaultConfig.User.Settings.NewSetting2 = "value"
defaultConfig.NewSection.Enabled = true
defaultConfig.NewSection.Value = "new section"
// Run migration
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
assert.False(t, result.Migrated, "No migration should be needed")
assert.Equal(t, 0, len(result.MissingFields), "No fields should be missing")
// No backup should be created
backupFiles, _ := filepath.Glob(filepath.Join(tempDir, "*.backup.*"))
assert.Equal(t, 0, len(backupFiles), "No backup should be created when migration is not needed")
}
// TestConfigMigrator_PartialOverride tests partial user override scenarios
func TestConfigMigrator_PartialOverride(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_partial_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Create user config with partial overrides
userConfig := map[string]interface{}{
"app": map[string]interface{}{
"name": "CustomApp",
"version": "2.0.0", // User custom value
// Missing: theme (should use default)
},
"user": map[string]interface{}{
"name": "Custom User",
"email": "custom@example.com",
"settings": map[string]interface{}{
"autoSave": false, // User custom value
"language": "zh", // User custom value
// Missing: newSetting, newSetting2 (should use defaults)
},
},
// Missing: newSection (should use defaults)
}
configPath := filepath.Join(tempDir, "config.json")
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
os.WriteFile(configPath, jsonData, 0644)
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
k := koanf.New(".")
k.Load(file.Provider(configPath), jsonparser.Parser())
// Create complete default config
defaultConfig := TestConfig{}
defaultConfig.App.Name = "DefaultApp"
defaultConfig.App.Version = "1.0.0"
defaultConfig.App.Theme = "light" // Should be added
defaultConfig.User.Name = "Default User"
defaultConfig.User.Email = "default@example.com"
defaultConfig.User.Settings.AutoSave = true
defaultConfig.User.Settings.Language = "en"
defaultConfig.User.Settings.NewSetting = true // Should be added
defaultConfig.User.Settings.NewSetting2 = "value" // Should be added
defaultConfig.NewSection.Enabled = true // Should be added
defaultConfig.NewSection.Value = "new section" // Should be added
// Run migration
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
assert.True(t, result.Migrated)
// Verify user values are preserved
assert.Equal(t, "CustomApp", k.String("app.name"))
assert.Equal(t, "2.0.0", k.String("app.version"))
assert.Equal(t, "Custom User", k.String("user.name"))
assert.Equal(t, "custom@example.com", k.String("user.email"))
assert.False(t, k.Bool("user.settings.autoSave"))
assert.Equal(t, "zh", k.String("user.settings.language"))
// Verify missing fields were added with defaults
assert.Equal(t, "light", k.String("app.theme"))
assert.True(t, k.Bool("user.settings.newSetting"))
assert.Equal(t, "value", k.String("user.settings.newSetting2"))
assert.True(t, k.Bool("newSection.enabled"))
assert.Equal(t, "new section", k.String("newSection.value"))
}
// TestConfigMigrator_ArrayMerge tests array and slice handling
func TestConfigMigrator_ArrayMerge(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_array_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Config with arrays
type ArrayConfig struct {
Plugins struct {
Enabled []string `json:"enabled"`
Config struct {
LoadOrder []string `json:"loadOrder"`
Settings map[string]string `json:"settings"`
} `json:"config"`
} `json:"plugins"`
Database struct {
Hosts []string `json:"hosts"`
Ports []int `json:"ports"`
} `json:"database"`
}
// User config with some arrays
userConfig := map[string]interface{}{
"plugins": map[string]interface{}{
"enabled": []string{"plugin1", "plugin2"}, // User's plugin list
// Missing: config section
},
// Missing: database section
}
configPath := filepath.Join(tempDir, "config.json")
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
os.WriteFile(configPath, jsonData, 0644)
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
k := koanf.New(".")
k.Load(file.Provider(configPath), jsonparser.Parser())
// Create default config with arrays
defaultConfig := ArrayConfig{}
defaultConfig.Plugins.Enabled = []string{"defaultPlugin1", "defaultPlugin2"}
defaultConfig.Plugins.Config.LoadOrder = []string{"plugin1", "plugin2"}
defaultConfig.Plugins.Config.Settings = map[string]string{"timeout": "30"}
defaultConfig.Database.Hosts = []string{"localhost", "backup.host"}
defaultConfig.Database.Ports = []int{5432, 5433}
// Run migration
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
assert.True(t, result.Migrated)
// User's array should be preserved
assert.Equal(t, []string{"plugin1", "plugin2"}, k.Strings("plugins.enabled"))
// Missing arrays should be added from defaults
assert.Equal(t, []string{"plugin1", "plugin2"}, k.Strings("plugins.config.loadOrder"))
assert.Equal(t, []string{"localhost", "backup.host"}, k.Strings("database.hosts"))
assert.Equal(t, []int{5432, 5433}, k.Ints("database.ports"))
expectedSettings := map[string]string{"timeout": "30"}
assert.Equal(t, expectedSettings, k.StringMap("plugins.config.settings"))
}
// TestConfigMigrator_DeepNesting tests very deep nested structures
func TestConfigMigrator_DeepNesting(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_deep_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Deep nested config
type DeepConfig struct {
Level1 struct {
Level2 struct {
Level3 struct {
Level4 struct {
Level5 struct {
Value string `json:"value"`
Count int `json:"count"`
} `json:"level5"`
} `json:"level4"`
} `json:"level3"`
} `json:"level2"`
} `json:"level1"`
}
// User config with partial deep nesting
userConfig := map[string]interface{}{
"level1": map[string]interface{}{
"level2": map[string]interface{}{
"level3": map[string]interface{}{
// Missing level4 completely
},
},
},
}
configPath := filepath.Join(tempDir, "config.json")
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
os.WriteFile(configPath, jsonData, 0644)
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
k := koanf.New(".")
k.Load(file.Provider(configPath), jsonparser.Parser())
// Create default config with complete deep nesting
defaultConfig := DeepConfig{}
defaultConfig.Level1.Level2.Level3.Level4.Level5.Value = "deep_value"
defaultConfig.Level1.Level2.Level3.Level4.Level5.Count = 42
// Run migration
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
assert.True(t, result.Migrated)
// Verify deep nested values were added
assert.Equal(t, "deep_value", k.String("level1.level2.level3.level4.level5.value"))
assert.Equal(t, 42, k.Int("level1.level2.level3.level4.level5.count"))
t.Logf("Missing fields: %v", result.MissingFields)
assert.Equal(t, 2, len(result.MissingFields), "Should detect 2 missing deep nested fields")
}
// TestConfigMigrator_EdgeCases tests various edge cases
func TestConfigMigrator_EdgeCases(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_edge_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
// Edge case config with various data types
userConfig := map[string]interface{}{
"string_empty": "",
"string_spaces": " ",
"number_zero": 0,
"number_float": 3.14,
"bool_false": false,
"array_empty": []interface{}{},
"map_empty": map[string]interface{}{},
"null_value": nil,
}
configPath := filepath.Join(tempDir, "config.json")
jsonData, _ := json.MarshalIndent(userConfig, "", " ")
os.WriteFile(configPath, jsonData, 0644)
logger := log.New()
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
k := koanf.New(".")
k.Load(file.Provider(configPath), jsonparser.Parser())
// Default config with different values
type EdgeConfig struct {
StringEmpty string `json:"string_empty"`
StringSpaces string `json:"string_spaces"`
NumberZero int `json:"number_zero"`
NumberFloat float64 `json:"number_float"`
BoolFalse bool `json:"bool_false"`
ArrayEmpty []string `json:"array_empty"`
MapEmpty map[string]interface{} `json:"map_empty"`
NullValue *string `json:"null_value"`
NewField string `json:"new_field"` // This should be added
}
defaultConfig := EdgeConfig{}
defaultConfig.StringEmpty = "default_string"
defaultConfig.StringSpaces = "default_spaces"
defaultConfig.NumberZero = 42
defaultConfig.NumberFloat = 2.71
defaultConfig.BoolFalse = true
defaultConfig.ArrayEmpty = []string{"default"}
defaultConfig.MapEmpty = map[string]interface{}{"key": "value"}
defaultValue := "default_null"
defaultConfig.NullValue = &defaultValue
defaultConfig.NewField = "new_field_value"
// Run migration
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
// All user edge case values should be preserved (they exist, even if empty/zero/false)
assert.Equal(t, "", k.String("string_empty"))
assert.Equal(t, " ", k.String("string_spaces"))
assert.Equal(t, 0, k.Int("number_zero"))
assert.Equal(t, 3.14, k.Float64("number_float"))
assert.False(t, k.Bool("bool_false"))
assert.Equal(t, []string{}, k.Strings("array_empty"))
// Only truly missing field should be added
assert.Equal(t, "new_field_value", k.String("new_field"))
// Should detect 2 missing fields: new_field and map_empty.key
// The user has an empty map, but default config has a key inside that map
assert.Equal(t, 2, len(result.MissingFields), "Should detect 2 missing fields: new_field and map_empty.key")
assert.Contains(t, result.MissingFields, "new_field")
assert.Contains(t, result.MissingFields, "map_empty.key")
// Verify that the key was added to the empty map
assert.Equal(t, "value", k.String("map_empty.key"))
}
// TestConfigMigrator_ErrorHandling tests error conditions
func TestConfigMigrator_ErrorHandling(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_error_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
logger := log.New()
configPath := filepath.Join(tempDir, "nonexistent.json")
migrator := NewConfigMigrator(logger, tempDir, "config", configPath)
// Test with empty koanf (no config file loaded)
k := koanf.New(".")
defaultConfig := TestConfig{}
defaultConfig.App.Name = "TestApp"
// This should still work (creates config from scratch)
result, err := migrator.AutoMigrate(defaultConfig, k)
assert.NoError(t, err)
assert.True(t, result.Migrated)
assert.Greater(t, len(result.MissingFields), 0)
// Test with corrupted config file
corruptedPath := filepath.Join(tempDir, "corrupted.json")
os.WriteFile(corruptedPath, []byte("{invalid json"), 0644)
k2 := koanf.New(".")
// This should fail gracefully when trying to load the corrupted file
err = k2.Load(file.Provider(corruptedPath), jsonparser.Parser())
assert.Error(t, err, "Should fail to load corrupted JSON")
}
// TestConfigMigrator_ConcurrentAccess tests concurrent migration access
func TestConfigMigrator_ConcurrentAccess(t *testing.T) {
tempDir, err := os.MkdirTemp("", "config_migrator_concurrent_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
configPath := createTestConfig(t, tempDir)
logger := log.New()
// Create multiple migrators
numWorkers := 5
results := make(chan *MigrationResult, numWorkers)
errors := make(chan error, numWorkers)
defaultConfig := TestConfig{}
defaultConfig.App.Name = "TestApp"
defaultConfig.App.Version = "1.0.0"
defaultConfig.App.Theme = "dark"
defaultConfig.User.Name = "Test User"
defaultConfig.User.Email = "test@example.com"
defaultConfig.User.Settings.AutoSave = true
defaultConfig.User.Settings.Language = "en"
defaultConfig.User.Settings.NewSetting = true
defaultConfig.User.Settings.NewSetting2 = "value"
defaultConfig.NewSection.Enabled = true
defaultConfig.NewSection.Value = "new section"
// Run concurrent migrations
for i := 0; i < numWorkers; i++ {
go func(workerID int) {
// Each worker gets its own config path to avoid file conflicts
workerConfigPath := filepath.Join(tempDir, fmt.Sprintf("config_%d.json", workerID))
// Copy the original config for this worker
originalData, _ := os.ReadFile(configPath)
os.WriteFile(workerConfigPath, originalData, 0644)
migrator := NewConfigMigrator(logger, tempDir, fmt.Sprintf("config_%d", workerID), workerConfigPath)
k := koanf.New(".")
k.Load(file.Provider(workerConfigPath), jsonparser.Parser())
result, err := migrator.AutoMigrate(defaultConfig, k)
if err != nil {
errors <- err
return
}
results <- result
}(i)
}
// Collect results
for i := 0; i < numWorkers; i++ {
select {
case result := <-results:
assert.True(t, result.Migrated, "Each worker should successfully migrate")
assert.Equal(t, 4, len(result.MissingFields), "Each worker should detect same missing fields")
case err := <-errors:
t.Errorf("Worker failed: %v", err)
}
}
} }

View File

@@ -109,9 +109,6 @@ func (cs *ConfigService) initConfig() error {
return cs.createDefaultConfig() return cs.createDefaultConfig()
} }
// 检查并自动迁移配置
cs.checkConfigMigration()
// 配置文件存在,直接加载现有配置 // 配置文件存在,直接加载现有配置
cs.fileProvider = file.Provider(cs.settingsPath) cs.fileProvider = file.Provider(cs.settingsPath)
if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil { if err := cs.koanf.Load(cs.fileProvider, jsonparser.Parser()); err != nil {
@@ -121,8 +118,8 @@ func (cs *ConfigService) initConfig() error {
return nil return nil
} }
// checkConfigMigration 检查配置迁移 // MigrateConfig 执行配置迁移
func (cs *ConfigService) checkConfigMigration() error { func (cs *ConfigService) MigrateConfig() error {
if cs.configMigrator == nil { if cs.configMigrator == nil {
return nil return nil
} }

View File

@@ -6,7 +6,7 @@ package services
#cgo CFLAGS: -I../lib #cgo CFLAGS: -I../lib
#cgo LDFLAGS: -luser32 #cgo LDFLAGS: -luser32
#include "../lib/hotkey_windows.c" #include "../lib/hotkey_windows.c"
#include "hotkey_windows.h" #include "../lib/hotkey_windows.h"
*/ */
import "C" import "C"

View File

@@ -5,7 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/creativeprojects/go-selfupdate" "github.com/creativeprojects/go-selfupdate"
"github.com/wailsapp/wails/v3/pkg/services/badge"
"github.com/wailsapp/wails/v3/pkg/services/log" "github.com/wailsapp/wails/v3/pkg/services/log"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
"os" "os"
"runtime" "runtime"
"time" "time"
@@ -28,6 +30,8 @@ type SelfUpdateResult struct {
type SelfUpdateService struct { type SelfUpdateService struct {
logger *log.LogService logger *log.LogService
configService *ConfigService configService *ConfigService
badgeService *badge.BadgeService // 直接使用Wails原生badge服务
notificationService *notifications.NotificationService // 通知服务
config *models.AppConfig config *models.AppConfig
// 状态管理 // 状态管理
@@ -35,7 +39,7 @@ type SelfUpdateService struct {
} }
// NewSelfUpdateService 创建自我更新服务实例 // NewSelfUpdateService 创建自我更新服务实例
func NewSelfUpdateService(configService *ConfigService, logger *log.LogService) (*SelfUpdateService, error) { func NewSelfUpdateService(configService *ConfigService, badgeService *badge.BadgeService, notificationService *notifications.NotificationService, logger *log.LogService) (*SelfUpdateService, error) {
// 获取配置 // 获取配置
appConfig, err := configService.GetConfig() appConfig, err := configService.GetConfig()
if err != nil { if err != nil {
@@ -45,6 +49,8 @@ func NewSelfUpdateService(configService *ConfigService, logger *log.LogService)
service := &SelfUpdateService{ service := &SelfUpdateService{
logger: logger, logger: logger,
configService: configService, configService: configService,
badgeService: badgeService,
notificationService: notificationService,
config: appConfig, config: appConfig,
isUpdating: false, isUpdating: false,
} }
@@ -63,6 +69,7 @@ func (s *SelfUpdateService) CheckForUpdates(ctx context.Context) (*SelfUpdateRes
// 首先尝试主要更新源 // 首先尝试主要更新源
primaryResult, err := s.checkSourceForUpdates(ctx, s.config.Updates.PrimarySource) primaryResult, err := s.checkSourceForUpdates(ctx, s.config.Updates.PrimarySource)
if err == nil && primaryResult != nil { if err == nil && primaryResult != nil {
s.handleUpdateBadge(primaryResult)
return primaryResult, nil return primaryResult, nil
} }
@@ -71,9 +78,12 @@ func (s *SelfUpdateService) CheckForUpdates(ctx context.Context) (*SelfUpdateRes
if backupErr != nil { if backupErr != nil {
// 如果备用源也失败,返回主要源的错误信息 // 如果备用源也失败,返回主要源的错误信息
result.Error = fmt.Sprintf("Primary source error: %v; Backup source error: %v", err, backupErr) result.Error = fmt.Sprintf("Primary source error: %v; Backup source error: %v", err, backupErr)
// 确保在检查失败时也调用handleUpdateBadge来清除可能存在的badge
s.handleUpdateBadge(result)
return result, errors.New(result.Error) return result, errors.New(result.Error)
} }
s.handleUpdateBadge(backupResult)
return backupResult, nil return backupResult, nil
} }
@@ -251,7 +261,6 @@ func (s *SelfUpdateService) ApplyUpdate(ctx context.Context) (*SelfUpdateResult,
// 检查是否有可用更新 // 检查是否有可用更新
if !primaryRelease.GreaterThan(s.config.Updates.Version) { if !primaryRelease.GreaterThan(s.config.Updates.Version) {
s.logger.Info("Current version is up to date, no need to apply update")
result.LatestVersion = primaryRelease.Version() result.LatestVersion = primaryRelease.Version()
return result, nil return result, nil
} }
@@ -310,6 +319,18 @@ func (s *SelfUpdateService) ApplyUpdate(ctx context.Context) (*SelfUpdateResult,
s.logger.Error("Failed to update config version", "error", err) s.logger.Error("Failed to update config version", "error", err)
} }
// 执行配置迁移
if err := s.configService.MigrateConfig(); err != nil {
s.logger.Error("Failed to migrate config after update", "error", err)
}
// 更新成功移除badge
if s.badgeService != nil {
if err := s.badgeService.RemoveBadge(); err != nil {
s.logger.Error("failed to remove update badge after successful update", "error", err)
}
}
return result, nil return result, nil
} }
@@ -365,7 +386,6 @@ func (s *SelfUpdateService) updateFromSource(ctx context.Context, sourceType mod
} }
// 尝试下载并应用更新,不设置超时 // 尝试下载并应用更新,不设置超时
s.logger.Info("Downloading update...", "source", sourceType)
err = updater.UpdateTo(ctx, release, exe) err = updater.UpdateTo(ctx, release, exe)
if err != nil { if err != nil {
@@ -392,6 +412,13 @@ func (s *SelfUpdateService) updateFromSource(ctx context.Context, sourceType mod
s.logger.Error("Failed to update config version", "error", err) s.logger.Error("Failed to update config version", "error", err)
} }
// 更新成功移除badge
if s.badgeService != nil {
if err := s.badgeService.RemoveBadge(); err != nil {
s.logger.Error("failed to remove update badge after successful update", "error", err)
}
}
return result, nil return result, nil
} }
@@ -462,3 +489,63 @@ func (s *SelfUpdateService) cleanupBackup(backupPath string) error {
} }
return nil return nil
} }
// handleUpdateBadge 处理更新通知badge和通知
func (s *SelfUpdateService) handleUpdateBadge(result *SelfUpdateResult) {
if result != nil && result.HasUpdate {
// 有更新时显示更新badge
if s.badgeService != nil {
if err := s.badgeService.SetBadge("●"); err != nil {
s.logger.Error("failed to set update badge", "error", err)
}
}
// 发送简单通知
s.sendUpdateNotification(result)
} else {
// 没有更新或出错时移除badge
if s.badgeService != nil {
if err := s.badgeService.RemoveBadge(); err != nil {
s.logger.Error("failed to remove update badge", "error", err)
}
}
}
}
// sendUpdateNotification 发送更新通知
func (s *SelfUpdateService) sendUpdateNotification(result *SelfUpdateResult) {
if s.notificationService == nil {
return
}
// 检查通知授权macOS需要
authorized, err := s.notificationService.CheckNotificationAuthorization()
if err != nil {
s.logger.Error("Failed to check notification authorization", "error", err)
return
}
if !authorized {
authorized, err = s.notificationService.RequestNotificationAuthorization()
if err != nil || !authorized {
s.logger.Error("Failed to get notification authorization", "error", err)
return
}
}
// 构建简单通知内容
title := "Voidraft Update Available"
body := fmt.Sprintf("New version %s available (current: %s)", result.LatestVersion, result.CurrentVersion)
// 发送简单通知
err = s.notificationService.SendNotification(notifications.NotificationOptions{
ID: "update_available",
Title: title,
Subtitle: "New version available",
Body: body,
})
if err != nil {
s.logger.Error("Failed to send notification", "error", err)
return
}
}

View File

@@ -4,7 +4,9 @@ import (
"voidraft/internal/models" "voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/badge"
"github.com/wailsapp/wails/v3/pkg/services/log" "github.com/wailsapp/wails/v3/pkg/services/log"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
) )
// ServiceManager 服务管理器,负责协调各个服务 // ServiceManager 服务管理器,负责协调各个服务
@@ -25,6 +27,9 @@ type ServiceManager struct {
selfUpdateService *SelfUpdateService selfUpdateService *SelfUpdateService
translationService *TranslationService translationService *TranslationService
themeService *ThemeService themeService *ThemeService
badgeService *badge.BadgeService
notificationService *notifications.NotificationService
testService *TestService // 测试服务(仅开发环境)
BackupService *BackupService BackupService *BackupService
logger *log.LogService logger *log.LogService
} }
@@ -34,6 +39,12 @@ func NewServiceManager() *ServiceManager {
// 初始化日志服务 // 初始化日志服务
logger := log.New() logger := log.New()
// 初始化badge服务
badgeService := badge.New()
// 初始化通知服务
notificationService := notifications.New()
// 初始化配置服务 // 初始化配置服务
configService := NewConfigService(logger) configService := NewConfigService(logger)
@@ -77,7 +88,7 @@ func NewServiceManager() *ServiceManager {
startupService := NewStartupService(configService, logger) startupService := NewStartupService(configService, logger)
// 初始化自我更新服务 // 初始化自我更新服务
selfUpdateService, err := NewSelfUpdateService(configService, logger) selfUpdateService, err := NewSelfUpdateService(configService, badgeService, notificationService, logger)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@@ -91,6 +102,9 @@ func NewServiceManager() *ServiceManager {
// 初始化备份服务 // 初始化备份服务
backupService := NewBackupService(configService, databaseService, logger) backupService := NewBackupService(configService, databaseService, logger)
// 初始化测试服务(开发环境使用)
testService := NewTestService(badgeService, notificationService, logger)
// 使用新的配置通知系统设置热键配置变更监听 // 使用新的配置通知系统设置热键配置变更监听
err = configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error { err = configService.SetHotkeyChangeCallback(func(enable bool, hotkey *models.HotkeyCombo) error {
return hotkeyService.UpdateHotkey(enable, hotkey) return hotkeyService.UpdateHotkey(enable, hotkey)
@@ -140,6 +154,9 @@ func NewServiceManager() *ServiceManager {
selfUpdateService: selfUpdateService, selfUpdateService: selfUpdateService,
translationService: translationService, translationService: translationService,
themeService: themeService, themeService: themeService,
badgeService: badgeService,
notificationService: notificationService,
testService: testService,
BackupService: backupService, BackupService: backupService,
logger: logger, logger: logger,
} }
@@ -164,6 +181,9 @@ func (sm *ServiceManager) GetServices() []application.Service {
application.NewService(sm.selfUpdateService), application.NewService(sm.selfUpdateService),
application.NewService(sm.translationService), application.NewService(sm.translationService),
application.NewService(sm.themeService), application.NewService(sm.themeService),
application.NewService(sm.badgeService),
application.NewService(sm.notificationService),
application.NewService(sm.testService), // 注册测试服务
application.NewService(sm.BackupService), application.NewService(sm.BackupService),
} }
return services return services
@@ -243,3 +263,13 @@ func (sm *ServiceManager) GetThemeService() *ThemeService {
func (sm *ServiceManager) GetWindowSnapService() *WindowSnapService { func (sm *ServiceManager) GetWindowSnapService() *WindowSnapService {
return sm.windowSnapService return sm.windowSnapService
} }
// GetBadgeService 获取badge服务实例
func (sm *ServiceManager) GetBadgeService() *badge.BadgeService {
return sm.badgeService
}
// GetNotificationService 获取通知服务实例
func (sm *ServiceManager) GetNotificationService() *notifications.NotificationService {
return sm.notificationService
}

View File

@@ -0,0 +1,139 @@
package services
import (
"context"
"fmt"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/badge"
"github.com/wailsapp/wails/v3/pkg/services/log"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
// TestService 测试服务 - 仅在开发环境使用
type TestService struct {
logger *log.LogService
badgeService *badge.BadgeService
notificationService *notifications.NotificationService
}
// NewTestService 创建测试服务实例
func NewTestService(badgeService *badge.BadgeService, notificationService *notifications.NotificationService, logger *log.LogService) *TestService {
if logger == nil {
logger = log.New()
}
return &TestService{
logger: logger,
badgeService: badgeService,
notificationService: notificationService,
}
}
// ServiceStartup 服务启动时调用
func (ts *TestService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
return nil
}
// TestBadge 测试Badge功能
func (ts *TestService) TestBadge(text string) error {
if ts.badgeService == nil {
return fmt.Errorf("badge service not available")
}
if text == "" {
// 如果文本为空则移除badge
err := ts.badgeService.RemoveBadge()
if err != nil {
ts.logger.Error("Failed to remove badge", "error", err)
return err
}
ts.logger.Info("Badge removed successfully")
return nil
}
// 设置badge
err := ts.badgeService.SetBadge(text)
if err != nil {
ts.logger.Error("Failed to set badge", "text", text, "error", err)
return err
}
ts.logger.Info("Badge set successfully", "text", text)
return nil
}
// TestNotification 测试通知功能
func (ts *TestService) TestNotification(title, subtitle, body string) error {
if ts.notificationService == nil {
return fmt.Errorf("notification service not available")
}
// 检查通知授权macOS需要
authorized, err := ts.notificationService.CheckNotificationAuthorization()
if err != nil {
ts.logger.Error("Failed to check notification authorization", "error", err)
return err
}
if !authorized {
authorized, err = ts.notificationService.RequestNotificationAuthorization()
if err != nil || !authorized {
ts.logger.Error("Failed to get notification authorization", "error", err)
return fmt.Errorf("notification authorization denied")
}
}
// 使用默认值如果参数为空
if title == "" {
title = "Test Notification"
}
if subtitle == "" {
subtitle = "Testing notification system"
}
if body == "" {
body = "This is a test notification to verify the system is working correctly."
}
// 发送测试通知
err = ts.notificationService.SendNotification(notifications.NotificationOptions{
ID: "test_notification",
Title: title,
Subtitle: subtitle,
Body: body,
})
if err != nil {
ts.logger.Error("Failed to send test notification", "error", err)
return err
}
ts.logger.Info("Test notification sent successfully", "title", title)
return nil
}
// TestUpdateNotification 测试更新通知
func (ts *TestService) TestUpdateNotification() error {
// 设置badge
if err := ts.TestBadge("●"); err != nil {
return err
}
// 发送更新通知
return ts.TestNotification(
"Voidraft Update Available",
"New version available",
"New version 1.2.3 available (current: 1.2.0)",
)
}
// ClearAll 清除所有测试状态
func (ts *TestService) ClearAll() error {
// 移除badge
if err := ts.TestBadge(""); err != nil {
ts.logger.Error("Failed to clear badge during cleanup", "error", err)
}
ts.logger.Info("Test states cleared successfully")
return nil
}

View File

@@ -11,10 +11,10 @@ import (
func SetupSystemTray(app *application.App, mainWindow *application.WebviewWindow, assets embed.FS, trayService *services.TrayService) { func SetupSystemTray(app *application.App, mainWindow *application.WebviewWindow, assets embed.FS, trayService *services.TrayService) {
// 创建系统托盘 // 创建系统托盘
systray := app.SystemTray.New() systray := app.SystemTray.New()
// 设置提示
systray.SetTooltip("voidraft")
// 设置标签 // 设置标签
systray.SetLabel("voidraft") systray.SetLabel("voidraft")
systray.Label()
// 设置图标 // 设置图标
iconBytes, _ := assets.ReadFile("appicon.png") iconBytes, _ := assets.ReadFile("appicon.png")
systray.SetIcon(iconBytes) systray.SetIcon(iconBytes)

View File

@@ -31,6 +31,19 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
REM Sync remote tags
echo [INFO] Syncing remote tags...
echo [INFO] Deleting local tags...
for /f "delims=" %%i in ('git tag -l') do git tag -d %%i >nul 2>&1
echo [INFO] Fetching remote tags...
git fetch origin --prune >nul 2>&1
if errorlevel 1 (
echo [WARNING] Failed to fetch from remote, using local tags
) else (
echo [INFO] Remote tags synced successfully
)
REM Get latest git tag REM Get latest git tag
git describe --abbrev=0 --tags > temp_tag.txt 2>nul git describe --abbrev=0 --tags > temp_tag.txt 2>nul
if errorlevel 1 ( if errorlevel 1 (

View File

@@ -19,6 +19,18 @@ else
echo "[ERROR] Not in a git repository" echo "[ERROR] Not in a git repository"
exit 1 exit 1
else else
# 同步远程标签
echo "[INFO] Syncing remote tags..."
echo "[INFO] Deleting local tags..."
git tag -l | xargs git tag -d &> /dev/null
echo "[INFO] Fetching remote tags..."
if git fetch origin --prune &> /dev/null; then
echo "[INFO] Remote tags synced successfully"
else
echo "[WARNING] Failed to fetch from remote, using local tags"
fi
# 获取最新的git标签 # 获取最新的git标签
LATEST_TAG=$(git describe --abbrev=0 --tags 2>/dev/null) LATEST_TAG=$(git describe --abbrev=0 --tags 2>/dev/null)

View File

@@ -1 +1 @@
VERSION=1.3.5 VERSION=1.3.7