Skip to content

Commit c87acbb

Browse files
committed
feat: initial patcher implementation using the diff tool
1 parent fd1c517 commit c87acbb

17 files changed

+1775
-321
lines changed

api/package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"@reduxjs/toolkit": "^2.3.0",
6161
"@reflet/cron": "^1.3.1",
6262
"@runonflux/nat-upnp": "^1.0.2",
63+
"@types/diff": "^7.0.1",
6364
"accesscontrol": "^2.2.1",
6465
"bycontract": "^2.0.11",
6566
"bytes": "^3.1.2",
@@ -73,6 +74,7 @@
7374
"convert": "^5.5.1",
7475
"cookie": "^1.0.2",
7576
"cross-fetch": "^4.0.0",
77+
"diff": "^7.0.0",
7678
"docker-event-emitter": "^0.3.0",
7779
"dockerode": "^3.3.5",
7880
"dotenv": "^16.4.5",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Logger } from '@nestjs/common';
2+
import { readFile, writeFile, access } from 'fs/promises';
3+
import { constants } from 'fs';
4+
import { join, dirname } from 'path';
5+
import { applyPatch, parsePatch, reversePatch } from 'diff';
6+
7+
export interface PatchResult {
8+
targetFile: string;
9+
patch: string;
10+
}
11+
12+
export interface ShouldApplyWithReason {
13+
shouldApply: boolean;
14+
reason: string;
15+
}
16+
17+
// Convert interface to abstract class with default implementations
18+
export abstract class FileModification {
19+
abstract id: string;
20+
21+
protected constructor(protected readonly logger: Logger) {}
22+
23+
// This is the main method that child classes need to implement
24+
protected abstract generatePatch(): Promise<PatchResult>;
25+
26+
private getPatchFilePath(targetFile: string): string {
27+
const dir = dirname(targetFile);
28+
const filename = `${this.id}.patch`;
29+
return join(dir, filename);
30+
}
31+
32+
private async savePatch(patchResult: PatchResult): Promise<void> {
33+
const patchFile = this.getPatchFilePath(patchResult.targetFile);
34+
await writeFile(patchFile, patchResult.patch, 'utf8');
35+
}
36+
37+
private async loadSavedPatch(targetFile: string): Promise<string | null> {
38+
const patchFile = this.getPatchFilePath(targetFile);
39+
try {
40+
await access(patchFile, constants.R_OK);
41+
return await readFile(patchFile, 'utf8');
42+
} catch {
43+
return null;
44+
}
45+
}
46+
47+
// Default implementation of apply that uses the patch
48+
async apply(): Promise<void> {
49+
const patchResult = await this.generatePatch();
50+
const { targetFile, patch } = patchResult;
51+
const currentContent = await readFile(targetFile, 'utf8');
52+
const parsedPatch = parsePatch(patch)[0];
53+
54+
const results = applyPatch(currentContent, parsedPatch);
55+
if (results === false) {
56+
throw new Error(`Failed to apply patch to ${targetFile}`);
57+
}
58+
59+
await writeFile(targetFile, results);
60+
await this.savePatch(patchResult);
61+
}
62+
63+
// Update rollback to use the shared utility
64+
async rollback(): Promise<void> {
65+
const { targetFile } = await this.generatePatch();
66+
let patch: string;
67+
68+
// Try to load saved patch first
69+
const savedPatch = await this.loadSavedPatch(targetFile);
70+
if (savedPatch) {
71+
this.logger.debug(`Using saved patch file for ${this.id}`);
72+
patch = savedPatch;
73+
} else {
74+
this.logger.debug(`No saved patch found for ${this.id}, generating new patch`);
75+
const patchResult = await this.generatePatch();
76+
patch = patchResult.patch;
77+
}
78+
79+
const currentContent = await readFile(targetFile, 'utf8');
80+
const parsedPatch = parsePatch(patch)[0];
81+
82+
if (!parsedPatch || !parsedPatch.hunks || parsedPatch.hunks.length === 0) {
83+
throw new Error('Invalid or empty patch content');
84+
}
85+
86+
const reversedPatch = reversePatch(parsedPatch);
87+
const results = applyPatch(currentContent, reversedPatch);
88+
89+
if (results === false) {
90+
throw new Error(`Failed to rollback patch from ${targetFile}`);
91+
}
92+
93+
await writeFile(targetFile, results);
94+
95+
// Clean up the patch file after successful rollback
96+
try {
97+
const patchFile = this.getPatchFilePath(targetFile);
98+
await access(patchFile, constants.W_OK);
99+
await unlink(patchFile);
100+
} catch {
101+
// Ignore errors when trying to delete the patch file
102+
}
103+
}
104+
105+
// Default implementation that can be overridden if needed
106+
async shouldApply(): Promise<ShouldApplyWithReason> {
107+
return {
108+
shouldApply: true,
109+
reason: 'Default behavior is to always apply modifications',
110+
};
111+
}
112+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
Index: /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php
2+
===================================================================
3+
--- /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php
4+
+++ /app/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/DefaultPageLayout.php
5+
@@ -557,14 +557,5 @@
6+
$.post('/webGui/include/Notify.php',{cmd:'get',csrf_token:csrf_token},function(msg) {
7+
$.each($.parseJSON(msg), function(i, notify){
8+
- $.jGrowl(notify.subject+'<br>'+notify.description,{
9+
- group: notify.importance,
10+
- header: notify.event+': '+notify.timestamp,
11+
- theme: notify.file,
12+
- sticky: true,
13+
- beforeOpen: function(e,m,o){if ($('div.jGrowl-notification').hasClass(notify.file)) return(false);},
14+
- afterOpen: function(e,m,o){if (notify.link) $(e).css('cursor','pointer');},
15+
- click: function(e,m,o){if (notify.link) location.replace(notify.link);},
16+
- close: function(e,m,o){$.post('/webGui/include/Notify.php',{cmd:'archive',file:notify.file,csrf_token:csrf_token});}
17+
- });
18+
+
19+
});
20+
});
21+
@@ -680,6 +671,6 @@
22+
}
23+
24+
-echo "<div class='nav-user show'><a id='board' href='#' class='hand'><b id='bell' class='icon-u-bell system'></b></a></div>";
25+
26+
+
27+
if ($themes2) echo "</div>";
28+
echo "</div></div>";
29+
@@ -886,20 +877,12 @@
30+
<?if ($notify['display']==0):?>
31+
if (notify.show) {
32+
- $.jGrowl(notify.subject+'<br>'+notify.description,{
33+
- group: notify.importance,
34+
- header: notify.event+': '+notify.timestamp,
35+
- theme: notify.file,
36+
- beforeOpen: function(e,m,o){if ($('div.jGrowl-notification').hasClass(notify.file)) return(false);},
37+
- afterOpen: function(e,m,o){if (notify.link) $(e).css('cursor','pointer');},
38+
- click: function(e,m,o){if (notify.link) location.replace(notify.link);},
39+
- close: function(e,m,o){$.post('/webGui/include/Notify.php',{cmd:'hide',file:"<?=$notify['path'].'/unread/'?>"+notify.file,csrf_token:csrf_token}<?if ($notify['life']==0):?>,function(){$.post('/webGui/include/Notify.php',{cmd:'archive',file:notify.file,csrf_token:csrf_token});}<?endif;?>);}
40+
- });
41+
+
42+
}
43+
<?endif;?>
44+
});
45+
- $('#bell').removeClass('red-orb yellow-orb green-orb').prop('title',"<?=_('Alerts')?> ["+bell1+']\n'+"<?=_('Warnings')?> ["+bell2+']\n'+"<?=_('Notices')?> ["+bell3+']');
46+
- if (bell1) $('#bell').addClass('red-orb'); else
47+
- if (bell2) $('#bell').addClass('yellow-orb'); else
48+
- if (bell3) $('#bell').addClass('green-orb');
49+
+
50+
+
51+
+
52+
+
53+
break;
54+
}
55+
@@ -1204,4 +1187,5 @@
56+
});
57+
</script>
58+
+<unraid-toaster rich-colors close-button position="<?= ($notify['position'] === 'center') ? 'top-center' : $notify['position'] ?>"></unraid-toaster>
59+
</body>
60+
</html>

api/src/unraid-api/unraid-file-modifier/modifications/__fixtures__/test-patch-file.txt

Whitespace-only changes.

0 commit comments

Comments
 (0)