Skip to content

Commit 1473323

Browse files
authored
Merge pull request #22 from simplecloudapp/feat/directoy-repository
Feat/directoy repository
2 parents 944494a + 40b123f commit 1473323

File tree

5 files changed

+431
-0
lines changed

5 files changed

+431
-0
lines changed
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
package app.simplecloud.plugin.api.shared.config.repository
2+
3+
import app.simplecloud.plugin.api.shared.config.repository.handler.FileHandler
4+
import app.simplecloud.plugin.api.shared.exception.RepositoryException
5+
import kotlinx.coroutines.*
6+
import org.slf4j.LoggerFactory
7+
import java.io.File
8+
import java.io.FileOutputStream
9+
import java.net.URL
10+
import java.nio.file.*
11+
import java.util.concurrent.ConcurrentHashMap
12+
import java.util.concurrent.atomic.AtomicReference
13+
import java.util.jar.JarFile
14+
import kotlin.coroutines.CoroutineContext
15+
16+
/**
17+
* A directory repository that manages files of a specific type.
18+
* Features:
19+
* - Thread-safe entity handling
20+
* - File watching with automatic reloading
21+
* - Entity change callbacks
22+
* - Validation support
23+
* - Automatic resource cleanup
24+
*
25+
* @param I The type of the identifier
26+
* @param T The type of the entity
27+
*/
28+
class DirectoryRepository<I : Any, T : Any> constructor(
29+
private val directory: Path,
30+
private val fileHandler: FileHandler<T>,
31+
private val coroutineContext: CoroutineContext,
32+
private val validator: ((T) -> Boolean)? = null
33+
) : AutoCloseable {
34+
35+
private val logger = LoggerFactory.getLogger(DirectoryRepository::class.java)
36+
private val entities = ConcurrentHashMap<File, AtomicReference<T>>()
37+
private val identifiers = ConcurrentHashMap<File, I>()
38+
private val changeListeners = mutableListOf<suspend (I, T?, T?) -> Unit>()
39+
private val errorHandlers = mutableListOf<(Exception) -> Unit>()
40+
private val saveLock = Object()
41+
private val scope = CoroutineScope(coroutineContext + SupervisorJob())
42+
private var watchJob: Job? = null
43+
44+
init {
45+
if (!directory.toFile().exists()) {
46+
directory.toFile().mkdirs()
47+
}
48+
}
49+
50+
/**
51+
* Loads all entities from the directory or creates them using default values.
52+
* @param defaultEntities Default entities to use if no existing files are found
53+
* @throws RepositoryException if loading or creation fails
54+
*/
55+
@Throws(RepositoryException::class)
56+
fun loadOrCreate(defaultEntities: Map<I, T> = emptyMap()) {
57+
try {
58+
if (directory.toFile().list()?.isEmpty() != false) {
59+
loadDefaultsFromResources(defaultEntities)
60+
}
61+
62+
load()
63+
registerWatcher()
64+
} catch (e: Exception) {
65+
handleError(RepositoryException("Failed to load repository", e))
66+
}
67+
}
68+
69+
private fun loadDefaultsFromResources(defaultEntities: Map<I, T>) {
70+
if (directory.toFile().list()?.isEmpty() == true) {
71+
val resourceUrl = DirectoryRepository::class.java.getResource("/defaults/") ?: run {
72+
println("Resource folder '/defaults/' not found.")
73+
return
74+
}
75+
76+
when (resourceUrl.protocol) {
77+
"file" -> handleFileProtocol(resourceUrl, directory.toFile())
78+
"jar" -> handleJarProtocol(resourceUrl, directory.toFile())
79+
80+
else -> println("Unsupported protocol: ${resourceUrl.protocol}")
81+
}
82+
83+
defaultEntities.forEach { (id, entity) -> save(id, entity) }
84+
}
85+
}
86+
87+
private fun handleFileProtocol(resourceUrl: URL, targetDirectory: File) {
88+
val resourceDir = File(resourceUrl.toURI())
89+
90+
if (resourceDir.exists()) {
91+
resourceDir.copyRecursively(targetDirectory, overwrite = true)
92+
} else {
93+
println("Resource directory does not exist: ${resourceUrl.path}")
94+
}
95+
}
96+
97+
private fun handleJarProtocol(resourceUrl: URL, targetDirectory: File) {
98+
val jarPath = resourceUrl.path.substringBefore("!").removePrefix("file:")
99+
100+
try {
101+
JarFile(jarPath).use { jarFile ->
102+
jarFile.entries().asSequence()
103+
.filter { it.name.startsWith("defaults/") && !it.isDirectory }
104+
.forEach { entry ->
105+
val targetFile = File(targetDirectory, entry.name.removePrefix("defaults/"))
106+
targetFile.parentFile.mkdirs()
107+
try {
108+
jarFile.getInputStream(entry).use { inputStream ->
109+
FileOutputStream(targetFile).use { fos ->
110+
fos.write(0xEF)
111+
fos.write(0xBB)
112+
fos.write(0xBF)
113+
inputStream.copyTo(fos)
114+
}
115+
}
116+
} catch (e: Exception) {
117+
println("Error copying file ${entry.name}: ${e.message}")
118+
}
119+
}
120+
}
121+
} catch (e: Exception) {
122+
println("Error processing JAR file: ${e.message}")
123+
e.printStackTrace()
124+
}
125+
}
126+
127+
private fun load() {
128+
Files.walk(directory)
129+
.filter { !it.toFile().isDirectory && it.toString().endsWith(fileHandler.fileExtension) }
130+
.forEach { loadFile(it.toFile()) }
131+
}
132+
133+
private fun loadFile(file: File) {
134+
try {
135+
fileHandler.load(file)?.let { entity ->
136+
if (validateEntity(entity)) {
137+
entities[file] = AtomicReference(entity)
138+
}
139+
}
140+
} catch (e: Exception) {
141+
handleError(RepositoryException("Error loading file ${file.name}", e))
142+
}
143+
}
144+
145+
/**
146+
* Saves an entity to the repository
147+
* @throws RepositoryException if saving fails or validation fails
148+
*/
149+
@Throws(RepositoryException::class)
150+
fun save(identifier: I, entity: T) {
151+
if (!validateEntity(entity)) {
152+
throw RepositoryException("Entity validation failed")
153+
}
154+
155+
synchronized(saveLock) {
156+
try {
157+
val file = getFile(identifier)
158+
file.parentFile?.mkdirs()
159+
160+
fileHandler.save(file, entity)
161+
val oldEntity = entities[file]?.get()
162+
entities[file] = AtomicReference(entity)
163+
identifiers[file] = identifier
164+
165+
scope.launch {
166+
notifyChangeListeners(identifier, oldEntity, entity)
167+
}
168+
} catch (e: Exception) {
169+
handleError(RepositoryException("Failed to save entity", e))
170+
throw e
171+
}
172+
}
173+
}
174+
175+
/**
176+
* Deletes an entity from the repository
177+
*/
178+
fun delete(identifier: I): Boolean {
179+
val file = getFile(identifier)
180+
if (!file.exists()) return false
181+
182+
synchronized(saveLock) {
183+
return try {
184+
val deleted = file.delete()
185+
186+
if (deleted) {
187+
val oldEntity = entities.remove(file)?.get()
188+
identifiers.remove(file)
189+
if (oldEntity != null) {
190+
scope.launch {
191+
notifyChangeListeners(identifier, oldEntity, null)
192+
}
193+
}
194+
}
195+
196+
deleted
197+
} catch (e: Exception) {
198+
handleError(RepositoryException("Failed to delete entity", e))
199+
false
200+
}
201+
}
202+
}
203+
204+
/**
205+
* Finds an entity by identifier
206+
*/
207+
fun find(identifier: I): T? =
208+
entities[getFile(identifier)]?.get()
209+
210+
211+
/**
212+
* Gets all entities in the repository
213+
*/
214+
fun getAll(): List<T> = entities.values.mapNotNull { it.get() }
215+
216+
/**
217+
* Gets all identifiers in the repository
218+
*/
219+
fun getAllIdentifiers(): Set<I> = identifiers.values.toSet()
220+
221+
/**
222+
* Adds a listener for entity changes
223+
*/
224+
fun onEntityChanged(listener: suspend (I, T?, T?) -> Unit) {
225+
synchronized(changeListeners) {
226+
changeListeners.add(listener)
227+
}
228+
}
229+
230+
/**
231+
* Adds an error handler
232+
*/
233+
fun onError(handler: (Exception) -> Unit) {
234+
synchronized(errorHandlers) {
235+
errorHandlers.add(handler)
236+
}
237+
}
238+
239+
private fun validateEntity(entity: T): Boolean {
240+
return validator?.invoke(entity) ?: fileHandler.validate(entity)
241+
}
242+
243+
private fun handleError(error: Exception) {
244+
logger.error(error.message, error)
245+
synchronized(errorHandlers) {
246+
errorHandlers.forEach { it(error) }
247+
}
248+
}
249+
250+
private suspend fun notifyChangeListeners(identifier: I, oldEntity: T?, newEntity: T?) {
251+
val listeners = synchronized(changeListeners) { changeListeners.toList() }
252+
253+
listeners.forEach { listener ->
254+
try {
255+
withContext(coroutineContext) {
256+
if (oldEntity != newEntity) {
257+
listener(identifier, oldEntity, newEntity)
258+
}
259+
}
260+
} catch (e: Exception) {
261+
handleError(RepositoryException("Error in change listener", e))
262+
}
263+
}
264+
}
265+
266+
private fun getFile(identifier: I): File =
267+
directory.resolve("$identifier${fileHandler.fileExtension}").toFile()
268+
269+
270+
private fun registerWatcher(): Job {
271+
val watchService = FileSystems.getDefault().newWatchService()
272+
directory.register(
273+
watchService,
274+
StandardWatchEventKinds.ENTRY_CREATE,
275+
StandardWatchEventKinds.ENTRY_MODIFY,
276+
StandardWatchEventKinds.ENTRY_DELETE
277+
)
278+
279+
return scope.launch {
280+
watchService.use { service ->
281+
while (isActive) {
282+
val key = service.take()
283+
284+
key.pollEvents().forEach { event ->
285+
handleWatchEvent(event)
286+
}
287+
288+
if (!key.reset()) break
289+
}
290+
}
291+
}.also { watchJob = it }
292+
}
293+
294+
private suspend fun handleWatchEvent(event: WatchEvent<*>) {
295+
val path = event.context() as? Path ?: return
296+
if (!path.toString().endsWith(fileHandler.fileExtension)) return
297+
298+
val resolvedPath = directory.resolve(path)
299+
if (Files.isDirectory(resolvedPath)) return
300+
301+
when (event.kind()) {
302+
StandardWatchEventKinds.ENTRY_CREATE,
303+
StandardWatchEventKinds.ENTRY_MODIFY -> {
304+
delay(100)
305+
loadFile(resolvedPath.toFile())
306+
}
307+
308+
StandardWatchEventKinds.ENTRY_DELETE -> {
309+
val file = resolvedPath.toFile()
310+
val identifier = identifiers[file]
311+
val oldEntity = entities.remove(file)?.get()
312+
313+
if (identifier != null && oldEntity != null) {
314+
notifyChangeListeners(identifier, oldEntity, null)
315+
}
316+
}
317+
318+
else -> {}
319+
}
320+
}
321+
322+
override fun close() {
323+
scope.cancel()
324+
watchJob?.cancel()
325+
}
326+
327+
companion object {
328+
/**
329+
* Creates a new DirectoryRepository instance
330+
*/
331+
inline fun <reified I : Any, reified T : Any> create(
332+
directory: Path,
333+
fileHandler: FileHandler<T>,
334+
noinline validator: ((T) -> Boolean)? = null,
335+
coroutineContext: CoroutineContext = Dispatchers.IO
336+
): DirectoryRepository<I, T> = DirectoryRepository(
337+
directory = directory,
338+
fileHandler = fileHandler,
339+
validator = validator,
340+
coroutineContext = coroutineContext
341+
)
342+
}
343+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package app.simplecloud.plugin.api.shared.config.repository.handler
2+
3+
import java.io.File
4+
5+
interface FileHandler<T : Any> {
6+
7+
val fileExtension: String
8+
9+
fun load(file: File): T?
10+
11+
fun save(file: File, entity: T)
12+
13+
fun validate(entity: T): Boolean = true
14+
15+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package app.simplecloud.plugin.api.shared.config.repository.handler
2+
3+
import java.awt.image.BufferedImage
4+
import java.io.File
5+
import javax.imageio.ImageIO
6+
7+
class PNGFileHandler : FileHandler<BufferedImage> {
8+
9+
override val fileExtension: String = ".png"
10+
11+
override fun load(file: File): BufferedImage? =
12+
runCatching { ImageIO.read(file) }.getOrNull()
13+
14+
15+
override fun save(file: File, entity: BufferedImage) {
16+
ImageIO.write(entity, "png", file)
17+
}
18+
19+
override fun validate(entity: BufferedImage): Boolean =
20+
entity.width > 0 && entity.height > 0
21+
22+
}

0 commit comments

Comments
 (0)