├── .dockerignore ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .idea └── .idea.Linkding.Sync │ └── .idea │ ├── .gitignore │ ├── GitLink.xml │ ├── active-tab-highlighter.xml │ ├── aws.xml │ ├── dbnavigator.xml │ ├── encodings.xml │ ├── git_toolbox_prj.xml │ ├── indexLayout.xml │ └── vcs.xml ├── Dockerfile_Linkding ├── Dockerfile_Wallabag ├── Linkding.Sync.sln ├── README.md ├── examples ├── linkding │ ├── config.yml │ └── docker-compose.yml └── wallabag │ ├── config.yml │ └── docker-compose.yml ├── global.json └── src ├── Domain └── Core │ ├── Abstraction │ ├── ILinkdingService.cs │ └── IWallabagService.cs │ ├── Converters │ ├── DateTimeConverterForCustomStandard.cs │ └── DateTimeOffsetConverterUsingDateTimeParse.cs │ ├── Core.csproj │ ├── Entities │ ├── Linkding │ │ ├── Bookmark.cs │ │ ├── BookmarkBase.cs │ │ ├── BookmarkCreatePayload.cs │ │ ├── BookmarkUpdatePayload.cs │ │ ├── BookmarkUpdateResult.cs │ │ ├── BookmarksResult.cs │ │ ├── HandlerResult.cs │ │ ├── Tag.cs │ │ └── TagCreatePayload.cs │ └── Wallabag │ │ ├── Annotation.cs │ │ ├── Embedded.cs │ │ ├── Headers.cs │ │ ├── HttpLink.cs │ │ ├── Links.cs │ │ ├── QueryLinks.cs │ │ ├── Range.cs │ │ ├── Tag.cs │ │ ├── WallabagItem.cs │ │ └── WallabagQuery.cs │ └── Handler │ ├── ILinkdingSyncTaskHandler.cs │ ├── ILinkdingTagTaskHandler.cs │ ├── ILinkdingTaskHandler.cs │ └── ISyncTaskHandler.cs ├── Linkding ├── Dockerfile ├── Extensions │ ├── ServiceRegistrationExtensions.cs │ └── StringExtensions.cs ├── Handler │ ├── AddPopularSitesAsTagHandler.cs │ ├── AddYearToBookmarkHandler.cs │ ├── NormalizeTagHandler.cs │ └── UpdateTargetLinkdingHandler.cs ├── Linkding.csproj ├── Options │ └── WorkerSettings.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Settings │ └── SettingsService.cs ├── Worker.cs ├── appsettings.Development.json ├── appsettings.json └── data │ └── config.yml ├── Services ├── Linkding.Client │ ├── Automapper │ │ └── LinkdingBookmarkProfile.cs │ ├── Extensions │ │ ├── BookmarkExtensions.cs │ │ ├── ServiceRegistrationExtensions.cs │ │ ├── System.cs │ │ └── SystemCollectionGenericExtesions.cs │ ├── Linkding.Client.csproj │ ├── LinkdingService.cs │ └── Options │ │ └── LinkdingSettings.cs └── Wallabag.Client │ ├── Contracts │ └── IAccessTokenProvider.cs │ ├── Converters │ ├── DateTimeConverterForCustomStandard.cs │ └── DateTimeOffsetConverterUsingDateTimeParse.cs │ ├── Extensions │ ├── ServiceRegistrationExtensions.cs │ └── URIExtensions.cs │ ├── Models │ ├── WallabagEntry.cs │ └── WallabagPayload.cs │ ├── OAuth │ ├── AuthenticationClient.cs │ └── OAuthTokenProvider.cs │ ├── Options │ └── WallabagSettings.cs │ ├── Wallabag.Client.csproj │ ├── WallabagServiceBase.cs │ └── WallabagServiceEntries.cs └── Wallabag ├── Extensions └── ServiceRegistrationExtensions.cs ├── Handler └── LinkdingBookmarkToWallabagHandler.cs ├── Options └── WorkerSettings.cs ├── Program.cs ├── Properties └── launchSettings.json ├── Settings └── SettingsService.cs ├── Wallabag.csproj ├── Worker.cs ├── appsettings.Development.json ├── appsettings.json └── data └── config.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.dockerignore 2 | **/.env 3 | **/.git 4 | **/.gitignore 5 | **/.project 6 | **/.settings 7 | **/.toolstarget 8 | **/.vs 9 | **/.vscode 10 | **/.idea 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | **/appsettings.json 25 | **/appsettings.*.json 26 | LICENSE 27 | README.md -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Images to GHCR 2 | 3 | env: 4 | DOTNET_VERSION: '8.0.x' 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | push-store-image: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 'Checkout GitHub Action' 17 | uses: actions/checkout@main 18 | 19 | - name: 'Login to GitHub Container Registry' 20 | uses: docker/login-action@v1 21 | with: 22 | registry: ghcr.io 23 | username: ${{github.actor}} 24 | password: ${{secrets.GITHUB_TOKEN}} 25 | 26 | - name: 'Build WallabagSync Image' 27 | run: | 28 | docker build -f ./Dockerfile_Wallabag --tag ghcr.io/spaytac/wallabag-sync:latest . 29 | docker push ghcr.io/spaytac/wallabag-sync:latest 30 | 31 | - name: 'Build LinkdingUpdater Image' 32 | run: | 33 | docker build -f ./Dockerfile_Linkding --tag ghcr.io/spaytac/linkding-updater:latest . 34 | docker push ghcr.io/spaytac/linkding-updater:latest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | /packages/ 4 | riderModule.iml 5 | /_ReSharper.Caches/ 6 | **/.env 7 | .env 8 | compose/ 9 | compose-test/ 10 | .vscode/ 11 | .idea/ -------------------------------------------------------------------------------- /.idea/.idea.Linkding.Sync/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Rider ignored files 5 | /contentModel.xml 6 | /.idea.Linkding.Sync.iml 7 | /modules.xml 8 | /projectSettingsUpdater.xml 9 | # Editor-based HTTP Client requests 10 | /httpRequests/ 11 | # Datasource local storage ignored files 12 | /dataSources/ 13 | /dataSources.local.xml 14 | -------------------------------------------------------------------------------- /.idea/.idea.Linkding.Sync/.idea/GitLink.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/.idea.Linkding.Sync/.idea/active-tab-highlighter.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/.idea.Linkding.Sync/.idea/aws.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/.idea.Linkding.Sync/.idea/dbnavigator.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | -------------------------------------------------------------------------------- /.idea/.idea.Linkding.Sync/.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/.idea.Linkding.Sync/.idea/git_toolbox_prj.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /.idea/.idea.Linkding.Sync/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | examples 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/.idea.Linkding.Sync/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Dockerfile_Linkding: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base 2 | WORKDIR /app 3 | 4 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 5 | WORKDIR /src 6 | COPY ["src/Linkding/Linkding.csproj", "src/Linkding/"] 7 | RUN dotnet restore "src/Linkding/Linkding.csproj" 8 | COPY . . 9 | WORKDIR "/src/src/Linkding" 10 | RUN dotnet build "Linkding.csproj" -c Release -o /app/build 11 | 12 | FROM build AS publish 13 | RUN dotnet publish "Linkding.csproj" -c Release -o /app/publish 14 | 15 | FROM base AS final 16 | WORKDIR /app 17 | COPY --from=publish /app/publish . 18 | RUN mkdir ./data 19 | ENTRYPOINT ["dotnet", "Linkding.dll"] 20 | -------------------------------------------------------------------------------- /Dockerfile_Wallabag: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base 2 | WORKDIR /app 3 | 4 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 5 | WORKDIR /src 6 | COPY ["src/Wallabag/Wallabag.csproj", "src/Wallabag/"] 7 | RUN dotnet restore "src/Wallabag/Wallabag.csproj" 8 | COPY . . 9 | WORKDIR "/src/src/Wallabag" 10 | RUN dotnet build "Wallabag.csproj" -c Release -o /app/build 11 | 12 | FROM build AS publish 13 | RUN dotnet publish "Wallabag.csproj" -c Release -o /app/publish 14 | 15 | FROM base AS final 16 | WORKDIR /app 17 | COPY --from=publish /app/publish . 18 | RUN mkdir ./data 19 | ENTRYPOINT ["dotnet", "Wallabag.dll"] 20 | -------------------------------------------------------------------------------- /Linkding.Sync.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3167CB7E-6412-49DB-BB42-E91E07B51C5E}" 4 | ProjectSection(SolutionItems) = preProject 5 | README.md = README.md 6 | EndProjectSection 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{2775FBD9-7954-4A8B-8831-31C7670209A3}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Linkding.Client", "src\Services\Linkding.Client\Linkding.Client.csproj", "{1880BB32-4013-45F5-9DB4-6864F9836525}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wallabag.Client", "src\Services\Wallabag.Client\Wallabag.Client.csproj", "{2776E05F-F35A-4421-A792-0F9B0CC48475}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Domain", "Domain", "{586A8F51-1A5D-42B9-8A37-8B2010905EB1}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "src\Domain\Core\Core.csproj", "{0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25}" 17 | EndProject 18 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{953FCC4E-C759-47DD-B20A-78FBA2E34F35}" 19 | EndProject 20 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wallabag", "src\Wallabag\Wallabag.csproj", "{5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177}" 21 | EndProject 22 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Linkding", "src\Linkding\Linkding.csproj", "{3E78F171-D237-46DF-8A27-DAADE7CA1940}" 23 | EndProject 24 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Items", "Items", "{328C408E-A1CD-4CDC-B13D-A4EC2F8C8494}" 25 | ProjectSection(SolutionItems) = preProject 26 | .dockerignore = .dockerignore 27 | .gitignore = .gitignore 28 | Dockerfile_Linkding = Dockerfile_Linkding 29 | Dockerfile_Wallabag = Dockerfile_Wallabag 30 | EndProjectSection 31 | EndProject 32 | Global 33 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 34 | Debug|Any CPU = Debug|Any CPU 35 | Release|Any CPU = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 38 | {1880BB32-4013-45F5-9DB4-6864F9836525}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {1880BB32-4013-45F5-9DB4-6864F9836525}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {1880BB32-4013-45F5-9DB4-6864F9836525}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {1880BB32-4013-45F5-9DB4-6864F9836525}.Release|Any CPU.Build.0 = Release|Any CPU 42 | {2776E05F-F35A-4421-A792-0F9B0CC48475}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {2776E05F-F35A-4421-A792-0F9B0CC48475}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {2776E05F-F35A-4421-A792-0F9B0CC48475}.Release|Any CPU.ActiveCfg = Release|Any CPU 45 | {2776E05F-F35A-4421-A792-0F9B0CC48475}.Release|Any CPU.Build.0 = Release|Any CPU 46 | {0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25}.Release|Any CPU.ActiveCfg = Release|Any CPU 49 | {0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25}.Release|Any CPU.Build.0 = Release|Any CPU 50 | {5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 51 | {5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177}.Debug|Any CPU.Build.0 = Debug|Any CPU 52 | {5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177}.Release|Any CPU.ActiveCfg = Release|Any CPU 53 | {5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177}.Release|Any CPU.Build.0 = Release|Any CPU 54 | {3E78F171-D237-46DF-8A27-DAADE7CA1940}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 55 | {3E78F171-D237-46DF-8A27-DAADE7CA1940}.Debug|Any CPU.Build.0 = Debug|Any CPU 56 | {3E78F171-D237-46DF-8A27-DAADE7CA1940}.Release|Any CPU.ActiveCfg = Release|Any CPU 57 | {3E78F171-D237-46DF-8A27-DAADE7CA1940}.Release|Any CPU.Build.0 = Release|Any CPU 58 | EndGlobalSection 59 | GlobalSection(NestedProjects) = preSolution 60 | {2775FBD9-7954-4A8B-8831-31C7670209A3} = {3167CB7E-6412-49DB-BB42-E91E07B51C5E} 61 | {1880BB32-4013-45F5-9DB4-6864F9836525} = {2775FBD9-7954-4A8B-8831-31C7670209A3} 62 | {2776E05F-F35A-4421-A792-0F9B0CC48475} = {2775FBD9-7954-4A8B-8831-31C7670209A3} 63 | {586A8F51-1A5D-42B9-8A37-8B2010905EB1} = {3167CB7E-6412-49DB-BB42-E91E07B51C5E} 64 | {0EA1E116-6560-4C1D-8EC8-6ACE3F5E1C25} = {586A8F51-1A5D-42B9-8A37-8B2010905EB1} 65 | {953FCC4E-C759-47DD-B20A-78FBA2E34F35} = {3167CB7E-6412-49DB-BB42-E91E07B51C5E} 66 | {5EFB26BC-CD34-4DE6-B0E3-9F1E387D4177} = {3167CB7E-6412-49DB-BB42-E91E07B51C5E} 67 | {3E78F171-D237-46DF-8A27-DAADE7CA1940} = {3167CB7E-6412-49DB-BB42-E91E07B51C5E} 68 | EndGlobalSection 69 | EndGlobal 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Linkding Sync 2 | LinkdingSync is a collection of tools that make life with [Linkding](https://github.com/sissbruecker/linkding) easier. 3 | 4 | One of the workers is for syncing to [Wallabag](https://wallabag.org/en). 5 | 6 | ## Getting Started 7 | It is recommended to use the Docker images. Otherwise, a .NET 8 environment is required to customize and build the code. 8 | 9 | ## Environment Variables 10 | For the containers to work, the environment variables must be passed. This can be done either directly via the Docker run **-e** switch, via the **environment** settings in a Docker compose definition, or via an environment variable file. 11 | 12 | ### WallabagSync 13 | Environment variables for the wallabag worker. 14 | 15 | | Variable | Value | Description | Attention | 16 | |------------------------|-----------|--------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| 17 | | Worker__Interval | int (>=0) | This value sets the execution schedule in minutes. 1 = every minute, 10 = every 10 minutes (default value 0) | 0 = runs only one time. The container will be stopped after the execution. This method is the preferred way to run the container with a scheduler (e.g. cron) | 18 | | Worker__SyncTag | text | The linkding tag to create the bookmarks in Wallabag. (default value 'readlater') | | 19 | | Linkding__Url | text | URL to the linkding instance | | 20 | | Linkding__Key | text | The linkding application key | [Instructions](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | 21 | | Wallabag__Url | text | URL to the Wallabag instance | | 22 | | Wallabag__Username | text | Wallabag User Name | | 23 | | Wallabag__Password | text | Wallabag User Password | | 24 | | Wallabag__ClientId | text | Wallabag Client Id | | 25 | | Wallabag__ClientSecret | text | Wallabag Client Secret | | 26 | 27 | ### LinkdingUpdater 28 | Environment variables for the linkding worker. 29 | 30 | | Variable | Value | Description | Attention | 31 | |-------------------------------|-----------|--------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| 32 | | Worker__Interval | int (>=0) | This value sets the execution schedule in minutes. 1 = every minute, 10 = every 10 minutes | 0 = runs only one time. The container will be stopped after the execution. This method is the preferred way to run the container with a scheduler (e.g. cron) | 33 | | Worker__Worker__TagNameLength | int | The max tag name length. Default is 64 characters | | 34 | | Worker__Tasks__X | text | The tasks which should be executed. X is the array index for the tasks entry | currently supported tasks: AddPopularSitesAsTag,AddYearToBookmark. | 35 | | Linkding__Url | text | URL to the linkding instance | | 36 | | Linkding__Key | text | The linkding application key | [Instructions](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | 37 | 38 | #### Tasks 39 | Tasks define the logic that directly make changes to the bookmarks. 40 | Currently available tasks: 41 | 42 | | Name | Description | 43 | |-----------------------|------------------------------------------------------------------------------------------------------------------------------------| 44 | | AddYearToBookmark | If the bookmark does not have the year of the creation date as a tag, it will be added. | 45 | | AddPopularSitesAsTag | This is the task for dynamic creation of tags. For this task to work, the rules must be passed to the container as configuration. | 46 | 47 | The tasks are defined as environment variables, if no tasks are defined, the container will be executed but no changes will be made. 48 | The tasks are passed as follows: 49 | ```yaml 50 | version: '3.9' 51 | 52 | services: 53 | linkdingupdater: 54 | image: ghcr.io/spaytac/linkding-updater:latest 55 | volumes: 56 | - ./config.yml:/app/data/config.yml 57 | # env_file: 58 | # - .env 59 | environment: 60 | - Worker__Interval=0 61 | - Worker__TagNameLength=64 62 | - Worker__Tasks__0=AddPopularSitesAsTag 63 | - Worker__Tasks__1=AddYearToBookmark 64 | - Linkding__Url=https:// 65 | - Linkding__Key= 66 | 67 | 68 | ``` 69 | 70 | ## Configuration 71 | The following explains the configuration options. The Configuration file must be mapped to **/app/data/config.yml** 72 | ### WallabagSync 73 | The configuration is optional. In the configuration (**YAML File**) rules can be defined in regex to exclude certain domains from sync. 74 | 75 | Exampel: 76 | ```yaml 77 | excludedDomains: 78 | - name: youtube 79 | pattern: 'https://[[a-zA-Z0-9]+\.]?(youtube)\.com(?:/.*)?' 80 | - name: ebay 81 | pattern: 'https://[[a-zA-Z0-9]+\.]?(ebay)\.(com|de|fr)(?:/.*)?' 82 | - name: amazon 83 | pattern: 'https://[[a-zA-Z0-9]+\.]?(amazon)\.(com|de|fr)(?:/.*)?' 84 | ``` 85 | With this configuration every matching bookmark from linkding will be excluded from the sync. 86 | 87 | ### LinkdingUpdater 88 | The configuration is optional. In the configuration (**YAML File**) rules can be defined in regex to assign tags dynamically. Additionally tags can be defined to domains. 89 | 90 | If operated without a configuration file, only the year of the tag is added (currently). 91 | 92 | Example: 93 | ```yaml 94 | urlTagMapping: 95 | - name: microsoft_azure 96 | url: https://github.com/azure 97 | - name: microsoft_azuread 98 | url: https://github.com/AzureAD 99 | - name: microsoft_dotnet 100 | url: https://github.com/dotnet-architecture 101 | 102 | taggingRule: 103 | - name: reddit 104 | pattern: https://(?:www\.)?(reddit)\.com(?:/r/)?([a-zA-Z0-9\-\+_]+)?(?:/.*)? 105 | replace: $1,$2 106 | - name: microsoft 107 | pattern: https://([a-zA-Z0-9]+)?[\.]?(microsoft)\.com(?:/.*)? 108 | replace: $1,$2 109 | - name: microsoft_docs 110 | pattern: 'https://(?:docs)\.(?:microsoft)\.com[/]?(?: [a-zA-Z0-9\-\+_]+)(?:/)?([a-zA-Z0-9\-\+_]+)?(?:/)?([a-zA-Z0-9\-\+_]+)?(?:/.*)?' 111 | replace: $1,$2 112 | - name: youtube 113 | pattern: https://[[a-zA-Z0-9]+\.]?(youtube)\.com(?:/.*)? 114 | replace: $1 115 | - name: ebay 116 | pattern: https://[[a-zA-Z0-9]+\.]?(ebay)\.(com|de|fr)(?:/.*)? 117 | replace: $1 118 | - name: amazon 119 | pattern: https://[[a-zA-Z0-9]+\.]?(amazon)\.(com|de|fr)(?:/.*)? 120 | replace: $1 121 | - name: docker 122 | pattern: https://([a-zA-Z0-9]+)?[\.]?(docker)\.com(?:/.*)? 123 | replace: $1,$2 124 | - name: xbox 125 | pattern: https://[[a-zA-Z0-9]+\.]?(xbox)\.com(?:/.*)? 126 | replace: $1 127 | - name: github 128 | pattern: https://([a-zA-Z0-9]+)?[\.]?(github)\.com[/]?([a-zA-Z0-9\-\+_]+)(?:/)?([a-zA-Z0-9\-\+_]+)?(?:/.*)? 129 | replace: $1,$2,$3,$4 130 | - name: github.io 131 | pattern: https://([a-zA-Z0-9]+)\.(github)\.io[/]?([a-zA-Z0-9\-\+_]+)(?:/)?([a-zA-Z0-9\-\+_]+)?(?:/.*)? 132 | replace: $1,$2,$3 133 | ``` 134 | 135 | #### urlTagMapping 136 | If the bookmark should match one - or more - of the urlTagMappings, then value of name is added as tag to this bookmark. 137 | 138 | Example: 139 | https://github.com/azure/something will be tagged with microsoft_azure 140 | 141 | #### taggingRule 142 | Dynamic tags can be assigned to the bookmarks on the basis of the URL using regular expression. If one of the patterns matches, the values of the groups are added to the bookmark as a tag. 143 | 144 | Example: 145 | Here is an example using a reddit bookmark. 146 | Url: 147 | ``` 148 | https://www.reddit.com/r/selfhosted/comments/yzq6qp/running_a_mostly_sbcbased_nomad_cluster_in_my/?utm_source=share&utm_medium=android_app&utm_name=androidcss&utm_term=2&utm_content=share_button 149 | ``` 150 | Pattern: 151 | ```regex 152 | https://(?:www\.)?(reddit)\.com(?:/r/)?([a-zA-Z0-9\-\+_]+)?(?:/.*)? 153 | ``` 154 | Matches: 155 | - https://www.$${\color{red}reddit}$$.com/r/$${\color{red}selfhosted}$$/comments/yzq6qp/running_a_mostly_sbcbased_nomad_cluster_in_my/?utm_source=share&utm_medium=android_app&utm_name=androidcss&utm_term=2&utm_content=share_button 156 | 157 | if you would change the pattern to the following. 158 | Pattern: 159 | ```regex 160 | https://(?:www\.)?(reddit)\.com(?:/)?(r/[a-zA-Z0-9\-\+_]+)?(?:/.*)? 161 | ``` 162 | 163 | then the following would match. 164 | - https://www.$${\color{red}reddit}$$.com/$${\color{red}r/selfhosted}$$/comments/yzq6qp/running_a_mostly_sbcbased_nomad_cluster_in_my/?utm_source=share&utm_medium=android_app&utm_name=androidcss&utm_term=2&utm_content=share_button 165 | 166 | ## Docker Run 167 | ```bash 168 | docker run --rm -it --env-file .env -v /config.yml:/app/data/config.yml ghcr.io/spaytac/linkding-updater:latest 169 | ``` 170 | 171 | ```bash 172 | docker run --rm -it --env-file .env -v /config.yml:/app/data/config.yml ghcr.io/spaytac/wallabag-sync:latest 173 | ``` 174 | 175 | ## Docker Compose 176 | You can find [examples](./examples/) in the examples folder.. 177 | 178 | - [WallabagSync Example](./examples/wallabag/) 179 | - [LinkdingUpdater Example](./examples/linkding/) 180 | 181 | 182 | ## Build Docker Images 183 | 184 | ### LinkdingUpdater 185 | ``` 186 | docker build -t linkdingsync/linkding-updater:latest -f .\Dockerfile_Linkding . 187 | ``` 188 | 189 | ### WallabagSync 190 | ``` 191 | docker build -t linkdingsync/wallabag-sync:latest -f .\Dockerfile_Wallabag . 192 | ``` -------------------------------------------------------------------------------- /examples/linkding/config.yml: -------------------------------------------------------------------------------- 1 | urlTagMapping: 2 | - name: microsoft 3 | url: https://github.com/azure 4 | - name: microsoft 5 | url: https://github.com/AzureAD 6 | - name: microsoft 7 | url: https://github.com/dotnet-architecture 8 | 9 | taggingRule: 10 | - name: reddit 11 | pattern: https://(?:www\.)?(reddit)\.com(?:/r/)?([a-zA-Z0-9\-\+_]+)?(?:/.*)? 12 | replace: $1,$2 13 | - name: microsoft 14 | pattern: https://([a-zA-Z0-9]+)?[\.]?(microsoft)\.com(?:/.*)? 15 | replace: $1,$2 16 | - name: microsoft_docs 17 | pattern: 'https://(?:docs)\.(?:microsoft)\.com[/]?(?: [a-zA-Z0-9\-\+_]+)(?:/)?([a-zA-Z0-9\-\+_]+)?(?:/)?([a-zA-Z0-9\-\+_]+)?(?:/.*)?' 18 | replace: $1,$2 19 | - name: youtube 20 | pattern: https://[[a-zA-Z0-9]+\.]?(youtube)\.com(?:/.*)? 21 | replace: $1 22 | - name: ebay 23 | pattern: https://[[a-zA-Z0-9]+\.]?(ebay)\.(com|de|fr)(?:/.*)? 24 | replace: $1 25 | - name: amazon 26 | pattern: https://[[a-zA-Z0-9]+\.]?(amazon)\.(com|de|fr)(?:/.*)? 27 | replace: $1 28 | - name: docker 29 | pattern: https://([a-zA-Z0-9]+)?[\.]?(docker)\.com(?:/.*)? 30 | replace: $1,$2 31 | - name: xbox 32 | pattern: https://[[a-zA-Z0-9]+\.]?(xbox)\.com(?:/.*)? 33 | replace: $1 34 | - name: github 35 | pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(github)\.com[ / ]?([ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)? 36 | replace: $2,$3,$4 37 | - name: github.io 38 | pattern: https://([ a-zA-Z0-9 ]+)\.(github)\.io[ / ]?([ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)? 39 | replace: $1,$2,$3 -------------------------------------------------------------------------------- /examples/linkding/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | linkdingupdater: 5 | image: ghcr.io/spaytac/linkding-updater:latest 6 | volumes: 7 | - ./config.yml:/app/data/config.yml 8 | # env_file: 9 | # - .env 10 | environment: 11 | - Worker__Interval=0 12 | - Worker__TagNameLength=64 13 | - Worker__Tasks__0=AddPopularSitesAsTag 14 | - Worker__Tasks__1=AddYearToBookmark 15 | - Linkding__Url=https:// 16 | - Linkding__Key= 17 | -------------------------------------------------------------------------------- /examples/wallabag/config.yml: -------------------------------------------------------------------------------- 1 | excludedDomains: 2 | - name: youtube 3 | pattern: 'https://[[a-zA-Z0-9]+\.]?(youtube)\.com(?:/.*)?' 4 | - name: ebay 5 | pattern: 'https://[[a-zA-Z0-9]+\.]?(ebay)\.(com|de|fr)(?:/.*)?' 6 | - name: amazon 7 | pattern: 'https://[[a-zA-Z0-9]+\.]?(amazon)\.(com|de|fr)(?:/.*)?' -------------------------------------------------------------------------------- /examples/wallabag/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | wallabagsync: 5 | image: ghcr.io/spaytac/wallabag-sync:latest 6 | volumes: 7 | - ./config.yml:/app/data/config.yml 8 | # env_file: 9 | # - .env 10 | environment: 11 | - Worker__Interval=0 12 | - Worker__SyncTag= 13 | - Linkding__Url=https:// 14 | - Linkding__Key= 15 | - Wallabag__Url=https:// 16 | - Wallabag__Username= 17 | - Wallabag__Password= 18 | - Wallabag__ClientId= 19 | - Wallabag__ClientSecret= 20 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "8.0.0", 4 | "rollForward": "latestMinor", 5 | "allowPrerelease": false 6 | } 7 | } -------------------------------------------------------------------------------- /src/Domain/Core/Abstraction/ILinkdingService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Core.Entities.Linkding; 4 | 5 | namespace Linkding.Client 6 | { 7 | public interface ILinkdingService 8 | { 9 | Task> GetBookmarksAsync(int limit = 100, int offset = 0); 10 | Task> GetAllBookmarksAsync(); 11 | Task AddBookmarkCollectionAsync(IEnumerable bookmarks); 12 | Task AddBookmarkCollectionAsync(IEnumerable bookmarks); 13 | Task AddBookmarkAsync(BookmarkCreatePayload bookmark); 14 | 15 | Task UpdateBookmarkCollectionAsync(IEnumerable bookmarks); 16 | Task UpdateBookmarkAsync(int id, BookmarkUpdatePayload bookmark); 17 | Task GetBookmarkResultsAsync(int limit = 100, int offset = 0); 18 | Task GetBookmarkResultsAsync(string url); 19 | } 20 | } -------------------------------------------------------------------------------- /src/Domain/Core/Abstraction/IWallabagService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Net.Http; 3 | using System.Net.Http.Headers; 4 | using System.Threading.Tasks; 5 | using Core.Entities.Wallabag; 6 | 7 | namespace Core.Abstraction 8 | { 9 | public interface IWallabagService 10 | { 11 | Task GetAuthenticationHeaderAsync(IEnumerable scopes = null); 12 | 13 | Task GetAsync(string endpoint, IEnumerable scopes = null, 14 | bool httpCompletionResponseContentRead = false); 15 | 16 | Task GetJsonAsync(string endpoint, IEnumerable scopes = null); 17 | 18 | Task PostWithFormDataAsnyc(string endpoint, FormUrlEncodedContent content, 19 | IEnumerable scopes = null); 20 | 21 | Task PostAsync(string endpoint, HttpContent content, 22 | IEnumerable scopes = null); 23 | 24 | Task PostAsync(string endpoint, HttpRequestMessage request, 25 | IEnumerable scopes = null); 26 | 27 | Task PutAsync(string endpoint, HttpContent content, 28 | IEnumerable scopes = null); 29 | 30 | Task DeleteAsync(string endpoint, IEnumerable scopes = null, 31 | HttpContent content = null); 32 | 33 | Task> GetEntries(string format = "json", int limit = 50, bool full = false); 34 | Task GetEntryById(int id, string format = "json"); 35 | Task AddEntryByUrl(string url, IEnumerable tags = null, string format = "json"); 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /src/Domain/Core/Converters/DateTimeConverterForCustomStandard.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization; 5 | 6 | namespace Wallabag.Client.Converters 7 | { 8 | public class DateTimeConverterForCustomStandard : JsonConverter 9 | { 10 | public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 11 | { 12 | var dateTimeString = reader.GetString(); 13 | 14 | if (string.IsNullOrEmpty(dateTimeString)) 15 | { 16 | return DateTime.MinValue; 17 | } 18 | DateTime dt = DateTime.ParseExact(dateTimeString, "yyyy-MM-dd'T'HH:mm:ssK", 19 | CultureInfo.InvariantCulture, 20 | DateTimeStyles.AdjustToUniversal); 21 | 22 | return dt; 23 | } 24 | 25 | public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) 26 | { 27 | writer.WriteStringValue(value.ToString()); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/Domain/Core/Converters/DateTimeOffsetConverterUsingDateTimeParse.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace Wallabag.Client.Converters 6 | { 7 | public class DateTimeOffsetConverterUsingDateTimeParse : JsonConverter 8 | { 9 | public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 10 | { 11 | var dateTimeString = reader.GetString(); 12 | return DateTimeOffset.Parse(dateTimeString); 13 | } 14 | 15 | public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) 16 | { 17 | writer.WriteStringValue(value.ToString()); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/Domain/Core/Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.1 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Linkding/Bookmark.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Core.Entities.Linkding 5 | { 6 | public class Bookmark : BookmarkBase 7 | { 8 | [JsonPropertyName("id")] 9 | public int Id { get; set; } 10 | 11 | [JsonPropertyName("website_title")] 12 | public string WebsiteTitle { get; set; } 13 | [JsonPropertyName("website_description")] 14 | public string WebsiteDescription { get; set; } 15 | [JsonPropertyName("is_archived")] 16 | public bool IsArchived { get; set; } 17 | [JsonPropertyName("unread")] 18 | public bool Unread { get; set; } 19 | [JsonPropertyName("date_added")] 20 | public DateTime DateAdded { get; set; } 21 | [JsonPropertyName("date_modified")] 22 | public DateTime DateModified { get; set; } 23 | } 24 | } 25 | 26 | // "id": 1, 27 | // "url": "https://example.com", 28 | // "title": "Example title", 29 | // "description": "Example description", 30 | // "website_title": "Website title", 31 | // "website_description": "Website description", 32 | // "is_archived": false, 33 | // "unread": false, 34 | // "tag_names": [ 35 | // "tag1", 36 | // "tag2" 37 | // ], 38 | // "date_added": "2020-09-26T09:46:23.006313Z", 39 | // "date_modified": "2020-09-26T16:01:14.275335Z" -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Linkding/BookmarkBase.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Core.Entities.Linkding 5 | { 6 | public class BookmarkBase 7 | { 8 | [JsonPropertyName("url")] 9 | public string Url { get; set; } 10 | [JsonPropertyName("title")] 11 | public string Title { get; set; } 12 | [JsonPropertyName("description")] 13 | public string Description { get; set; } 14 | [JsonPropertyName("tag_names")] 15 | public IEnumerable TagNames { get; set; } = new List(); 16 | } 17 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Linkding/BookmarkCreatePayload.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Core.Entities.Linkding 4 | { 5 | public class BookmarkCreatePayload : BookmarkBase 6 | { 7 | [JsonPropertyName("is_archived")] 8 | public bool IsArchived { get; set; } = false; 9 | 10 | [JsonPropertyName("unread")] public bool Unread { get; set; } = false; 11 | } 12 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Linkding/BookmarkUpdatePayload.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Core.Entities.Linkding 4 | { 5 | public class BookmarkUpdatePayload : BookmarkBase 6 | { 7 | // [JsonPropertyName("id")] 8 | // public int Id { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Linkding/BookmarkUpdateResult.cs: -------------------------------------------------------------------------------- 1 | namespace Core.Entities.Linkding 2 | { 3 | public class BookmarkUpdateResult : BookmarkUpdatePayload 4 | { 5 | public bool Success { get; set; } = true; 6 | } 7 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Linkding/BookmarksResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Core.Entities.Linkding 4 | { 5 | public class BookmarksResult 6 | { 7 | public long Count { get; set; } 8 | public string? Next { get; set; } 9 | public string? Previous { get; set; } 10 | public List Results { get; set; } 11 | } 12 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Linkding/HandlerResult.cs: -------------------------------------------------------------------------------- 1 | using Core.Entities.Linkding; 2 | 3 | namespace LinkdingUpdater.Handler 4 | { 5 | public class HandlerResult 6 | { 7 | public bool PerformAction { get; set; } = false; 8 | 9 | public LinkdingItemAction Action { get; set; } 10 | public bool HasError { get; set; } = false; 11 | public string ErrorMessage { get; set; } = string.Empty; 12 | public Bookmark Instance { get; set; } 13 | } 14 | } 15 | 16 | public enum LinkdingItemAction 17 | { 18 | Update, 19 | Delete 20 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Linkding/Tag.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Core.Entities.Linkding 5 | { 6 | public class Tag 7 | { 8 | [JsonPropertyName("id")] 9 | public int Id { get; set; } 10 | [JsonPropertyName("name")] 11 | public string Name { get; set; } 12 | [JsonPropertyName("date_added")] 13 | public DateTime DateAdded { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Linkding/TagCreatePayload.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Core.Entities.Linkding 4 | { 5 | public class TagCreatePayload 6 | { 7 | [JsonPropertyName("name")] 8 | public string Name { get; set; } 9 | } 10 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Wallabag/Annotation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.Json.Serialization; 4 | using Wallabag.Client.Converters; 5 | 6 | namespace Core.Entities.Wallabag 7 | { 8 | public class Annotation 9 | { 10 | [JsonPropertyName("user")] public string User { get; set; } 11 | 12 | [JsonPropertyName("annotator_schema_version")] 13 | public string AnnotatorSchemaVersion { get; set; } 14 | 15 | [JsonPropertyName("id")] public int Id { get; set; } 16 | 17 | [JsonPropertyName("text")] public string Text { get; set; } 18 | 19 | 20 | [JsonConverter(typeof(DateTimeConverterForCustomStandard))] 21 | [JsonPropertyName("created_at")] public DateTime? CreatedAt { get; set; } = null; 22 | 23 | [JsonConverter(typeof(DateTimeConverterForCustomStandard))] 24 | [JsonPropertyName("updated_at")] public DateTime? UpdatedAt { get; set; } = null; 25 | 26 | [JsonPropertyName("quote")] public string Quote { get; set; } 27 | 28 | [JsonPropertyName("ranges")] public List Ranges { get; set; } 29 | } 30 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Wallabag/Embedded.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Core.Entities.Wallabag 5 | { 6 | public class Embedded 7 | { 8 | [JsonPropertyName("items")] 9 | public List Items { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Wallabag/Headers.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Core.Entities.Wallabag 4 | { 5 | public class Headers 6 | { 7 | [JsonPropertyName("server")] public string Server { get; set; } 8 | 9 | [JsonPropertyName("date")] public string Date { get; set; } 10 | 11 | [JsonPropertyName("content-type")] public string ContentType { get; set; } 12 | 13 | [JsonPropertyName("content-length")] public string ContentLength { get; set; } 14 | 15 | [JsonPropertyName("connection")] public string Connection { get; set; } 16 | 17 | [JsonPropertyName("x-powered-by")] public string XPoweredBy { get; set; } 18 | 19 | [JsonPropertyName("cache-control")] public string CacheControl { get; set; } 20 | 21 | [JsonPropertyName("etag")] public string Etag { get; set; } 22 | 23 | [JsonPropertyName("vary")] public string Vary { get; set; } 24 | 25 | [JsonPropertyName("strict-transport-security")] 26 | public string StrictTransportSecurity { get; set; } 27 | } 28 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Wallabag/HttpLink.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Core.Entities.Wallabag 4 | { 5 | public class HttpLink 6 | { 7 | [JsonPropertyName("href")] public string Href { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Wallabag/Links.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Core.Entities.Wallabag 4 | { 5 | public class Links 6 | { 7 | [JsonPropertyName("self")] public HttpLink? Self { get; set; } = null; 8 | } 9 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Wallabag/QueryLinks.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Core.Entities.Wallabag 4 | { 5 | public class QueryLinks : Links 6 | { 7 | [JsonPropertyName("first")] public HttpLink? First { get; set; } = null; 8 | 9 | [JsonPropertyName("last")] public HttpLink? Last { get; set; } = null; 10 | 11 | [JsonPropertyName("next")] public HttpLink? Next { get; set; } = null; 12 | } 13 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Wallabag/Range.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Core.Entities.Wallabag 4 | { 5 | public class Range 6 | { 7 | [JsonPropertyName("start")] public string Start { get; set; } 8 | 9 | [JsonPropertyName("startOffset")] public string StartOffset { get; set; } 10 | 11 | [JsonPropertyName("end")] public string End { get; set; } 12 | 13 | [JsonPropertyName("endOffset")] public string EndOffset { get; set; } 14 | } 15 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Wallabag/Tag.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Core.Entities.Wallabag 4 | { 5 | public class Tag 6 | { 7 | [JsonPropertyName("id")] public int Id { get; set; } 8 | 9 | [JsonPropertyName("label")] public string Label { get; set; } 10 | 11 | [JsonPropertyName("slug")] public string Slug { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Wallabag/WallabagItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.Json.Serialization; 4 | using Wallabag.Client.Converters; 5 | 6 | namespace Core.Entities.Wallabag 7 | { 8 | public class WallabagItem 9 | { 10 | [JsonPropertyName("is_archived")] public int IsArchived { get; set; } 11 | 12 | [JsonPropertyName("is_starred")] public int IsStarred { get; set; } 13 | 14 | [JsonPropertyName("user_name")] public string UserName { get; set; } 15 | 16 | [JsonPropertyName("user_email")] public string UserEmail { get; set; } 17 | 18 | [JsonPropertyName("user_id")] public int UserId { get; set; } 19 | 20 | [JsonPropertyName("tags")] public List Tags { get; set; } 21 | 22 | [JsonPropertyName("is_public")] public bool IsPublic { get; set; } 23 | 24 | [JsonPropertyName("id")] public int Id { get; set; } 25 | 26 | [JsonPropertyName("uid")] public string? Uid { get; set; } = null; 27 | 28 | [JsonPropertyName("title")] public string Title { get; set; } 29 | 30 | [JsonPropertyName("url")] public string Url { get; set; } 31 | 32 | [JsonPropertyName("hashed_url")] public string HashedUrl { get; set; } 33 | 34 | [JsonPropertyName("origin_url")] public string? OriginUrl { get; set; } = null; 35 | 36 | [JsonPropertyName("given_url")] public string GivenUrl { get; set; } 37 | 38 | [JsonPropertyName("hashed_given_url")] public string HashedGivenUrl { get; set; } 39 | 40 | [JsonConverter(typeof(DateTimeConverterForCustomStandard))] 41 | [JsonPropertyName("archived_at")] public DateTime? ArchivedAt { get; set; } = null; 42 | 43 | [JsonPropertyName("content")] public string? Content { get; set; } = null; 44 | 45 | [JsonConverter(typeof(DateTimeConverterForCustomStandard))] 46 | [JsonPropertyName("created_at")] public DateTime CreatedAt { get; set; } 47 | 48 | [JsonConverter(typeof(DateTimeConverterForCustomStandard))] 49 | [JsonPropertyName("updated_at")] public DateTime UpdatedAt { get; set; } 50 | 51 | [JsonConverter(typeof(DateTimeConverterForCustomStandard))] 52 | [JsonPropertyName("published_at")] public DateTime PublishedAt { get; set; } 53 | 54 | [JsonPropertyName("published_by")] public List PublishedBy { get; set; } 55 | 56 | [JsonConverter(typeof(DateTimeConverterForCustomStandard))] 57 | [JsonPropertyName("starred_at")] public DateTime? StarredAt { get; set; } = null; 58 | 59 | [JsonPropertyName("annotations")] public List Annotations { get; set; } 60 | 61 | [JsonPropertyName("mimetype")] public string Mimetype { get; set; } 62 | 63 | [JsonPropertyName("language")] public string Language { get; set; } 64 | 65 | [JsonPropertyName("reading_time")] public int ReadingTime { get; set; } 66 | 67 | [JsonPropertyName("domain_name")] public string DomainName { get; set; } 68 | 69 | [JsonPropertyName("preview_picture")] public string PreviewPicture { get; set; } 70 | 71 | [JsonPropertyName("http_status")] public string HttpStatus { get; set; } 72 | 73 | [JsonPropertyName("headers")] public Headers Headers { get; set; } 74 | 75 | [JsonPropertyName("_links")] public Links Links { get; set; } 76 | } 77 | } -------------------------------------------------------------------------------- /src/Domain/Core/Entities/Wallabag/WallabagQuery.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Core.Entities.Wallabag 4 | { 5 | public class WallabagQuery 6 | { 7 | [JsonPropertyName("page")] 8 | public int Page { get; set; } 9 | 10 | [JsonPropertyName("limit")] 11 | public int Limit { get; set; } 12 | 13 | [JsonPropertyName("pages")] 14 | public int Pages { get; set; } 15 | 16 | [JsonPropertyName("total")] 17 | public int Total { get; set; } 18 | 19 | [JsonPropertyName("_links")] 20 | public QueryLinks QueryLinks { get; set; } 21 | 22 | [JsonPropertyName("_embedded")] 23 | public Embedded Embedded { get; set; } 24 | } 25 | } -------------------------------------------------------------------------------- /src/Domain/Core/Handler/ILinkdingSyncTaskHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Core.Entities.Linkding; 4 | using Linkding.Client; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.Logging; 7 | 8 | namespace Core.Handler 9 | { 10 | public interface ILinkdingSyncTaskHandler 11 | { 12 | string Command { get; } 13 | Task ProcessAsync(IEnumerable bookmarks, ILinkdingService linkdingService, ILogger logger, IConfiguration configuration); 14 | } 15 | } -------------------------------------------------------------------------------- /src/Domain/Core/Handler/ILinkdingTagTaskHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Core.Entities.Linkding; 3 | using LinkdingUpdater.Handler; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace Core.Handler 8 | { 9 | public interface ILinkdingTagTaskHandler 10 | { 11 | string Command { get; } 12 | Task ProcessAsync(Tag tag, ILogger logger, IConfiguration configuration); 13 | } 14 | } -------------------------------------------------------------------------------- /src/Domain/Core/Handler/ILinkdingTaskHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Core.Entities.Linkding; 3 | using LinkdingUpdater.Handler; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace Core.Handler 8 | { 9 | public interface ILinkdingTaskHandler 10 | { 11 | string Command { get; } 12 | Task ProcessAsync(Bookmark bookmark, ILogger logger, IConfiguration configuration); 13 | } 14 | } -------------------------------------------------------------------------------- /src/Domain/Core/Handler/ISyncTaskHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Core.Entities.Wallabag; 5 | using Linkding.Client; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace Core.Handler 10 | { 11 | public interface ISyncTaskHandler 12 | { 13 | Type HandlerType { get; } 14 | string Command { get; } 15 | 16 | Task ProcessAsync(IEnumerable items, T destinationService, ILinkdingService linkdingService, ILogger logger, IConfiguration configuration); 17 | } 18 | } -------------------------------------------------------------------------------- /src/Linkding/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base 2 | WORKDIR /app 3 | 4 | FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build 5 | WORKDIR /src 6 | COPY ["src/LinkdingService/LinkdingService.csproj", "src/LinkdingService/"] 7 | RUN dotnet restore "src/LinkdingService/LinkdingService.csproj" 8 | COPY . . 9 | WORKDIR "/src/src/LinkdingService" 10 | RUN dotnet build "LinkdingService.csproj" -c Release -o /app/build 11 | 12 | FROM build AS publish 13 | RUN dotnet publish "LinkdingService.csproj" -c Release -o /app/publish 14 | 15 | FROM base AS final 16 | WORKDIR /app 17 | COPY --from=publish /app/publish . 18 | RUN mkdir ./data 19 | ENTRYPOINT ["dotnet", "LinkdingService.dll"] 20 | -------------------------------------------------------------------------------- /src/Linkding/Extensions/ServiceRegistrationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Linkding.Options; 2 | using Linkding.Settings; 3 | 4 | // ReSharper disable once CheckNamespace 5 | namespace Microsoft.Extensions.DependencyInjection; 6 | 7 | public static class ServiceRegistrationExtensions 8 | { 9 | public static IServiceCollection Add_Linkding_Worker(this IServiceCollection services, 10 | IConfiguration configuration) 11 | { 12 | var configSection = configuration.GetSection(WorkerSettings.Position); 13 | services.Configure(configSection); 14 | services.AddSingleton(); 15 | 16 | return services; 17 | } 18 | } -------------------------------------------------------------------------------- /src/Linkding/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Microsoft.Extensions.DependencyInjection; 2 | 3 | public static class StringExtensions 4 | { 5 | public static string NormalizeTag(this string tag, int maxTagLength = 64) 6 | { 7 | //in the database only 64 characters are allowed for tags 8 | if (tag.Length >= maxTagLength) 9 | { 10 | tag = tag.Substring(0, (maxTagLength)); 11 | } 12 | 13 | return tag; 14 | } 15 | } -------------------------------------------------------------------------------- /src/Linkding/Handler/AddPopularSitesAsTagHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.RegularExpressions; 3 | using Core.Abstraction; 4 | using Core.Entities.Linkding; 5 | using Core.Handler; 6 | using Linkding.Settings; 7 | using LinkdingUpdater.Handler; 8 | 9 | namespace Linkding.Handler; 10 | 11 | public class AddPopularSitesAsTagHandler : ILinkdingTaskHandler 12 | { 13 | public string Command { get; } = "AddPopularSitesAsTag"; 14 | public async Task ProcessAsync(Bookmark bookmark, ILogger logger, IConfiguration configuration) 15 | { 16 | var settings = SettingsService.Settings; 17 | 18 | var returnValue = new HandlerResult() {Instance = bookmark}; 19 | Regex r = null; 20 | Match m = null; 21 | foreach (var regexEntry in settings.taggingRule) 22 | { 23 | try 24 | { 25 | r = new Regex(regexEntry.pattern, RegexOptions.IgnoreCase); 26 | m = r.Match(returnValue.Instance.Url); 27 | if (m.Success) 28 | { 29 | var tagsCommaSeparated = r.Replace(returnValue.Instance.Url, regexEntry.replace); 30 | if (!string.IsNullOrEmpty(tagsCommaSeparated)) 31 | { 32 | var tags = tagsCommaSeparated.Split(','); 33 | foreach (var tag in tags) 34 | { 35 | var normalizeTag = tag.NormalizeTag(); 36 | if (!string.IsNullOrEmpty(normalizeTag) && !returnValue.Instance.TagNames.Contains(normalizeTag) && 37 | returnValue.Instance.TagNames.FirstOrDefault(x => x.ToLower() == normalizeTag.ToLower()) == null) 38 | { 39 | 40 | returnValue.Instance.TagNames = returnValue.Instance.TagNames.Add(normalizeTag); 41 | returnValue.PerformAction = true; 42 | returnValue.Action = LinkdingItemAction.Update; 43 | } 44 | } 45 | } 46 | } 47 | } 48 | finally 49 | { 50 | r = null; 51 | m = null; 52 | } 53 | 54 | } 55 | 56 | 57 | foreach (var urlKeyValue in settings.urlTagMapping) 58 | { 59 | if (returnValue.Instance.Url.ToLower().StartsWith(urlKeyValue.url.ToLower()) && returnValue.Instance.TagNames.FirstOrDefault(x => x.ToLower() == urlKeyValue.name.ToLower()) == null) 60 | { 61 | returnValue.Instance.TagNames = returnValue.Instance.TagNames.Add(urlKeyValue.name); 62 | 63 | returnValue.PerformAction = true; 64 | } 65 | } 66 | 67 | 68 | return returnValue; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Linkding/Handler/AddYearToBookmarkHandler.cs: -------------------------------------------------------------------------------- 1 | using Core.Abstraction; 2 | using Core.Entities.Linkding; 3 | using Core.Handler; 4 | using LinkdingUpdater.Handler; 5 | 6 | namespace Linkding.Handler; 7 | 8 | public class AddYearToBookmarkHandler : ILinkdingTaskHandler 9 | { 10 | public string Command { get; } = "AddYearToBookmark"; 11 | 12 | public async Task ProcessAsync(Bookmark bookmark, ILogger logger, IConfiguration configuration) 13 | { 14 | var returnValue = new HandlerResult() {Instance = bookmark}; 15 | 16 | var update = false; 17 | var createdYear = returnValue.Instance.DateAdded.GetYear(); 18 | 19 | if (createdYear != "1970") 20 | { 21 | var tagName = returnValue.Instance.TagNames.FirstOrDefault(x => x.Equals(createdYear)); 22 | if (tagName == null) 23 | { 24 | logger.LogInformation( 25 | $"Detected bookmark ({returnValue.Instance.WebsiteTitle} - {returnValue.Instance.Id}) without year-tag ... Try to update"); 26 | returnValue.Instance.TagNames = returnValue.Instance.TagNames.Add(createdYear); 27 | update = true; 28 | } 29 | } 30 | else 31 | { 32 | var wrongTagName = returnValue.Instance.TagNames.FirstOrDefault(x => x.Equals("1970")); 33 | if (wrongTagName != null) 34 | { 35 | logger.LogInformation( 36 | $"Detected bookmark ({returnValue.Instance.WebsiteTitle} - {returnValue.Instance.Id}) with '1970' year-tag ... Try to update"); 37 | returnValue.Instance.TagNames = returnValue.Instance.TagNames.Where(x => !x.Equals("1970")).Select(x => x); 38 | update = true; 39 | } 40 | } 41 | 42 | if (update) 43 | { 44 | logger.LogInformation($"Start updating bookmark {returnValue.Instance.WebsiteTitle} - {returnValue.Instance.Id}"); 45 | returnValue.PerformAction = true; 46 | returnValue.Action = LinkdingItemAction.Update; 47 | } 48 | 49 | return returnValue; 50 | } 51 | } -------------------------------------------------------------------------------- /src/Linkding/Handler/NormalizeTagHandler.cs: -------------------------------------------------------------------------------- 1 | using Core.Entities.Linkding; 2 | using Core.Handler; 3 | using LinkdingUpdater.Handler; 4 | 5 | namespace Linkding.Handler; 6 | 7 | public class NormalizeTagHandler : ILinkdingTaskHandler 8 | { 9 | public string Command { get; } = "NormalizeTag"; 10 | 11 | public async Task ProcessAsync(Bookmark bookmark, ILogger logger, IConfiguration configuration) 12 | { 13 | var returnValue = new HandlerResult() {Instance = bookmark}; 14 | var update = false; 15 | 16 | var maxTagLength = configuration.GetValue("Worker:TagNameLength"); 17 | 18 | var normalizedTagnames = new List(); 19 | foreach (var tagName in returnValue.Instance.TagNames) 20 | { 21 | if (tagName.Length > maxTagLength) 22 | { 23 | var normalizeTag = tagName.NormalizeTag(); 24 | normalizedTagnames.Add(normalizeTag); 25 | update = true; 26 | } 27 | else 28 | { 29 | normalizedTagnames.Add(tagName); 30 | } 31 | } 32 | 33 | if (update) 34 | { 35 | logger.LogInformation($"Start updating bookmark {returnValue.Instance.WebsiteTitle} - {returnValue.Instance.Id}"); 36 | returnValue.Instance.TagNames = normalizedTagnames; 37 | returnValue.PerformAction = true; 38 | returnValue.Action = LinkdingItemAction.Update; 39 | } 40 | 41 | return returnValue; 42 | } 43 | } -------------------------------------------------------------------------------- /src/Linkding/Handler/UpdateTargetLinkdingHandler.cs: -------------------------------------------------------------------------------- 1 | using Core.Entities.Linkding; 2 | using Core.Handler; 3 | using Linkding.Client; 4 | using Linkding.Client.Extensions; 5 | 6 | namespace Linkding.Handler; 7 | 8 | public class UpdateTargetLinkdingHandler : ILinkdingSyncTaskHandler 9 | { 10 | public string Command { get; } = "UpdateTargetLinkding"; 11 | 12 | public async Task ProcessAsync(IEnumerable bookmarks, ILinkdingService linkdingService, ILogger logger, 13 | IConfiguration configuration) 14 | { 15 | var linkdingBookmarks = await linkdingService.GetAllBookmarksAsync(); 16 | var addedBookmarks = new List(); 17 | var updatedBookmarks = new List(); 18 | 19 | if (linkdingBookmarks.Count() > 0) 20 | { 21 | 22 | foreach (var bookmark in bookmarks) 23 | { 24 | var linkdingBookmark = linkdingBookmarks.FirstOrDefault(x => x.Url == bookmark.Url); 25 | if (linkdingBookmark == null) 26 | { 27 | addedBookmarks.Add(bookmark.MapToCreatePayload()); 28 | } 29 | else 30 | { 31 | bookmark.Id = linkdingBookmark.Id; 32 | bookmark.Title = !string.IsNullOrEmpty(bookmark.Title) ? bookmark.Title.Trim() : bookmark.WebsiteTitle; 33 | bookmark.Description = !string.IsNullOrEmpty(bookmark.Description) ? bookmark.Description.Trim() : bookmark.WebsiteDescription; 34 | 35 | if (!string.IsNullOrEmpty(bookmark.Title) && !string.IsNullOrEmpty(bookmark.Description) && 36 | (!linkdingBookmark.Title.Equals(bookmark.Title.Trim(), StringComparison.OrdinalIgnoreCase) || 37 | !linkdingBookmark.Description.Equals(bookmark.Description.Trim(), StringComparison.OrdinalIgnoreCase) || 38 | linkdingBookmark.TagNames.Count() != bookmark.TagNames.Count())) 39 | { 40 | updatedBookmarks.Add(bookmark); 41 | } 42 | else 43 | { 44 | var difference = linkdingBookmark.TagNames.Where(t => 45 | !bookmark.TagNames.Any(b => b.Equals(t, StringComparison.OrdinalIgnoreCase))); 46 | 47 | if (difference.Count() > 0) 48 | { 49 | updatedBookmarks.Add(bookmark); 50 | } 51 | } 52 | } 53 | } 54 | } 55 | else 56 | { 57 | foreach (var bookmark in linkdingBookmarks) 58 | { 59 | addedBookmarks.Add(bookmark.MapToCreatePayload()); 60 | } 61 | } 62 | 63 | if (updatedBookmarks.Count > 0) 64 | { 65 | await linkdingService.UpdateBookmarkCollectionAsync(updatedBookmarks); 66 | } 67 | 68 | if (addedBookmarks.Count > 0) 69 | { 70 | await linkdingService.AddBookmarkCollectionAsync(addedBookmarks); 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /src/Linkding/Linkding.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | dotnet-LinkdingService-80165DE2-FA70-4803-B366-DF8F24CF86BE 8 | Linux 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | .dockerignore 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | Never 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Linkding/Options/WorkerSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Linkding.Options; 2 | 3 | public class WorkerSettings 4 | { 5 | public const string Position = "Worker"; 6 | 7 | public int Interval { get; set; } = 0; 8 | public int TagNameLength { get; set; } = 64; 9 | public List Tasks { get; set; } = new List(); 10 | public List TargetLinkdingUrl { get; set; } = new List(); 11 | public List TargetLinkdingKey { get; set; } = new List(); 12 | } -------------------------------------------------------------------------------- /src/Linkding/Program.cs: -------------------------------------------------------------------------------- 1 | using Linkding; 2 | 3 | IHost host = Host.CreateDefaultBuilder(args) 4 | .ConfigureServices((ctx, services) => 5 | { 6 | services.Add_Linkding_HttpClient(ctx.Configuration); 7 | services.Add_Linkding_Worker(ctx.Configuration); 8 | services.AddHostedService(); 9 | }).ConfigureHostConfiguration((builder) => 10 | { 11 | builder 12 | .AddEnvironmentVariables() 13 | #if DEBUG 14 | .AddJsonFile("appsettings.json") 15 | .AddJsonFile("appsettings.Development.json") 16 | #endif 17 | .AddUserSecrets(true) 18 | .AddCommandLine(args); 19 | }) 20 | .Build(); 21 | 22 | await host.RunAsync(); -------------------------------------------------------------------------------- /src/Linkding/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "LinkdingService": { 4 | "commandName": "Project", 5 | "dotnetRunMessages": true, 6 | "environmentVariables": { 7 | "DOTNET_ENVIRONMENT": "Development" 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Linkding/Settings/SettingsService.cs: -------------------------------------------------------------------------------- 1 | using YamlDotNet.Serialization; 2 | using YamlDotNet.Serialization.NamingConventions; 3 | 4 | namespace Linkding.Settings; 5 | 6 | public class SettingYaml 7 | { 8 | public List urlTagMapping { get; set; } = new (); 9 | public List taggingRule { get; set; } = new (); 10 | } 11 | 12 | public class UrlTagMapping 13 | { 14 | public string name { get; set; } 15 | public string url { get; set; } 16 | } 17 | 18 | public class TaggingRule 19 | { 20 | public string name { get; set; } 21 | public string pattern { get; set; } 22 | public string replace { get; set; } 23 | } 24 | 25 | public class SettingsService 26 | { 27 | private const string fileName = "data/config.yml"; 28 | 29 | private static SettingYaml _settings = null; 30 | 31 | public static SettingYaml Settings 32 | { 33 | get 34 | { 35 | if (_settings == null) 36 | { 37 | Initialize(); 38 | } 39 | 40 | return _settings; 41 | } 42 | private set 43 | { 44 | _settings = value; 45 | } 46 | } 47 | 48 | private static void Initialize() 49 | { 50 | var filePath = Path.Combine(Environment.CurrentDirectory, fileName); 51 | var fileInfo = new FileInfo(filePath); 52 | 53 | if (fileInfo.Exists) 54 | { 55 | var deserializer = new DeserializerBuilder() 56 | .WithNamingConvention(CamelCaseNamingConvention.Instance) // see height_in_inches in sample yml 57 | .Build(); 58 | 59 | var yml = File.ReadAllText(fileInfo.FullName); 60 | 61 | Settings = deserializer.Deserialize(yml); 62 | } 63 | else 64 | { 65 | Settings = new SettingYaml(); 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /src/Linkding/Worker.cs: -------------------------------------------------------------------------------- 1 | using Core.Abstraction; 2 | using Core.Entities.Linkding; 3 | using Core.Handler; 4 | using Linkding.Client; 5 | using Linkding.Client.Options; 6 | using Linkding.Options; 7 | using Microsoft.Extensions.Options; 8 | 9 | namespace Linkding; 10 | 11 | public class Worker : BackgroundService 12 | { 13 | private readonly IHostApplicationLifetime _hostApplicationLifetime; 14 | private readonly ILogger _logger; 15 | private readonly LinkdingService _linkdingService; 16 | private readonly LinkdingSettings _linkdingSettings; 17 | private readonly WorkerSettings _settings; 18 | private readonly IConfiguration _configuration; 19 | 20 | public Worker(ILogger logger, LinkdingService linkdingService, 21 | IOptions linkdingSettings, IOptions settings, IConfiguration configuration, IHostApplicationLifetime hostApplicationLifetime) 22 | { 23 | _logger = logger; 24 | _linkdingService = linkdingService; 25 | _configuration = configuration; 26 | _hostApplicationLifetime = hostApplicationLifetime; 27 | _settings = settings.Value; 28 | _linkdingSettings = linkdingSettings.Value; 29 | } 30 | 31 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 32 | { 33 | while (!stoppingToken.IsCancellationRequested) 34 | { 35 | _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); 36 | 37 | await RunBookmarksTaskHandler(); 38 | int delay = _settings.Interval * 60000; 39 | 40 | if (delay > 0) 41 | { 42 | _logger.LogInformation($"Worker paused for: {_settings.Interval} minutes"); 43 | 44 | await Task.Delay(delay, stoppingToken); 45 | } 46 | else 47 | { 48 | _logger.LogInformation($"Interval value is '0' --> stopping worker"); 49 | _hostApplicationLifetime.StopApplication(); 50 | } 51 | } 52 | } 53 | 54 | public async Task RunBookmarksTaskHandler() 55 | { 56 | IEnumerable linkdingBookmarks = null; 57 | if (!string.IsNullOrEmpty(_linkdingSettings.Url) && _linkdingSettings.UpdateBookmarks) 58 | { 59 | _logger.LogInformation($"Starting updating bookmarks for {_linkdingSettings.Url}"); 60 | 61 | _logger.LogInformation("Collecting Tag Handler"); 62 | var tagHandlers = AppDomain.CurrentDomain.GetAssemblies() 63 | .SelectMany(s => s.GetTypes()) 64 | .Where(p => typeof(ILinkdingTaskHandler).IsAssignableFrom(p) && p.IsClass); 65 | 66 | var updatedBookmarksCount = 0; 67 | var updateBookmarks = new List(); 68 | var deleteBookmarks = new List(); 69 | if (tagHandlers != null && tagHandlers.Count() > 0) 70 | { 71 | linkdingBookmarks = await _linkdingService.GetAllBookmarksAsync(); 72 | if (linkdingBookmarks.Count() > 0) 73 | { 74 | var tasksLowerCase = _settings.Tasks.Select(x => x.ToLower()); 75 | 76 | _logger.LogInformation($"{linkdingBookmarks.Count()} bookmarks found in {_linkdingSettings.Url}"); 77 | 78 | foreach (var handler in tagHandlers) 79 | { 80 | ILinkdingTaskHandler handlerInstance = null; 81 | try 82 | { 83 | handlerInstance = (ILinkdingTaskHandler) Activator.CreateInstance(handler); 84 | 85 | var task = tasksLowerCase.FirstOrDefault(x => 86 | x.Equals(handlerInstance.Command, StringComparison.InvariantCultureIgnoreCase)); 87 | 88 | if (string.IsNullOrEmpty(task)) 89 | { 90 | continue; 91 | } 92 | 93 | foreach (var linkdingBookmark in linkdingBookmarks) 94 | { 95 | try 96 | { 97 | _logger.LogDebug($"Start executing {task}"); 98 | // var updateBookmark = updateBookmarks.FirstOrDefault(x => x.Id == linkdingBookmark.Id); 99 | var existingBookmarkIndexInt = 100 | updateBookmarks.FindIndex(x => x.Id == linkdingBookmark.Id); 101 | 102 | var bookmarkInstance = existingBookmarkIndexInt != -1 103 | ? updateBookmarks[existingBookmarkIndexInt] 104 | : linkdingBookmark; 105 | 106 | var result = await handlerInstance.ProcessAsync(bookmarkInstance, _logger, _configuration); 107 | 108 | if (result.HasError) 109 | { 110 | _logger.LogWarning(result.ErrorMessage, handlerInstance.Command); 111 | } 112 | else 113 | { 114 | if (result.PerformAction) 115 | { 116 | if (result.Action == LinkdingItemAction.Delete) 117 | { 118 | if (existingBookmarkIndexInt != -1) 119 | { 120 | updateBookmarks.RemoveAt(existingBookmarkIndexInt); 121 | } 122 | 123 | var bookmarkToDelete = deleteBookmarks.FirstOrDefault(x => 124 | x.Url.ToLower() == result.Instance.Url.ToLower()); 125 | if (bookmarkToDelete == null) 126 | { 127 | deleteBookmarks.Add(result.Instance); 128 | } 129 | } 130 | else 131 | { 132 | if (existingBookmarkIndexInt != -1) 133 | { 134 | updateBookmarks[existingBookmarkIndexInt] = result.Instance; 135 | } 136 | else 137 | { 138 | updateBookmarks.Add(result.Instance); 139 | } 140 | 141 | linkdingBookmark.TagNames = result.Instance.TagNames; 142 | } 143 | } 144 | } 145 | 146 | _logger.LogDebug($"Finished {handlerInstance.Command}"); 147 | } 148 | catch (Exception e) 149 | { 150 | Console.WriteLine(e); 151 | var message = $"... {e.Message}"; 152 | 153 | if (handlerInstance != null && !string.IsNullOrEmpty(handlerInstance.Command)) 154 | { 155 | message = $"Error while executing {handlerInstance.Command}! {message}"; 156 | } 157 | else 158 | { 159 | message = $"Error while executing handler! {message}"; 160 | } 161 | 162 | _logger.LogError(message, "Calling Handler", e); 163 | // throw; 164 | } 165 | } 166 | } 167 | catch (Exception e) 168 | { 169 | Console.WriteLine(e); 170 | // throw; 171 | } 172 | } 173 | } 174 | else 175 | { 176 | _logger.LogInformation($"no bookmarks found in {_linkdingSettings.Url}"); 177 | } 178 | 179 | if (updateBookmarks.Count() > 0) 180 | { 181 | _logger.LogDebug($"Start updating bookmarks"); 182 | await _linkdingService.UpdateBookmarkCollectionAsync(updateBookmarks); 183 | _logger.LogDebug($"Successfully updated bookmarks"); 184 | } 185 | } 186 | 187 | _logger.LogInformation($"Finished updating bookmarks for {_linkdingSettings.Url}"); 188 | 189 | _logger.LogInformation("Collecting Sync Handler"); 190 | var syncHandlers = AppDomain.CurrentDomain.GetAssemblies() 191 | .SelectMany(s => s.GetTypes()) 192 | .Where(p => typeof(ILinkdingSyncTaskHandler).IsAssignableFrom(p) && p.IsClass); 193 | 194 | if (syncHandlers != null && syncHandlers.Count() > 0) 195 | { 196 | if (linkdingBookmarks == null) 197 | { 198 | linkdingBookmarks = await _linkdingService.GetAllBookmarksAsync(); 199 | } 200 | 201 | if (linkdingBookmarks.Count() > 0) 202 | { 203 | var tasksLowerCase = _settings.Tasks.Select(x => x.ToLower()); 204 | 205 | if (_settings.TargetLinkdingKey.Count == _settings.TargetLinkdingUrl.Count) 206 | { 207 | var targetInstances = new Dictionary(); 208 | for (var i = 0; i < _settings.TargetLinkdingKey.Count(); i++) 209 | { 210 | LinkdingService targetService = null; 211 | try 212 | { 213 | var url = _settings.TargetLinkdingUrl[i]; 214 | var key = _settings.TargetLinkdingKey[i]; 215 | targetService = LinkdingService.Create(url, key); 216 | 217 | foreach (var syncHandler in syncHandlers) 218 | { 219 | ILinkdingSyncTaskHandler handlerInstance = null; 220 | try 221 | { 222 | handlerInstance = (ILinkdingSyncTaskHandler) Activator.CreateInstance(syncHandler); 223 | 224 | var task = tasksLowerCase.FirstOrDefault(x => 225 | x.Equals(handlerInstance.Command, StringComparison.InvariantCultureIgnoreCase)); 226 | 227 | if (string.IsNullOrEmpty(task)) 228 | { 229 | continue; 230 | } 231 | 232 | _logger.LogDebug($"Start executing {task}"); 233 | await handlerInstance.ProcessAsync(linkdingBookmarks, targetService, _logger, _configuration); 234 | _logger.LogDebug($"{task} executed successfully!"); 235 | } 236 | catch (Exception ex) 237 | { 238 | Console.WriteLine(ex); 239 | var message = $"... {ex.Message}"; 240 | 241 | if (handlerInstance != null && !string.IsNullOrEmpty(handlerInstance.Command)) 242 | { 243 | message = $"Error while executing {handlerInstance.Command}! {message}"; 244 | } 245 | else 246 | { 247 | message = $"Error while executing handler! {message}"; 248 | } 249 | 250 | _logger.LogError(message, "Calling Handler", ex); 251 | } 252 | } 253 | } 254 | catch (Exception e) 255 | { 256 | Console.WriteLine(e); 257 | {throw;} 258 | } 259 | finally 260 | { 261 | targetService = null; 262 | } 263 | } 264 | } 265 | } 266 | } 267 | } 268 | } 269 | } -------------------------------------------------------------------------------- /src/Linkding/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.Hosting.Lifetime": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Linkding/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.Hosting.Lifetime": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Linkding/data/config.yml: -------------------------------------------------------------------------------- 1 | urlTagMapping: 2 | - name: microsoft 3 | url: https://github.com/azure 4 | - name: microsoft 5 | url: https://github.com/AzureAD 6 | - name: microsoft 7 | url: https://github.com/dotnet-architecture 8 | 9 | taggingRule: 10 | - name: reddit 11 | pattern: https://(?:www\.)?(reddit)\.com(?:/r/)?([a-zA-Z0-9\-\+_]+)?(?:/.*)? 12 | replace: $1,$2 13 | - name: microsoft 14 | pattern: https://([a-zA-Z0-9]+)?[\.]?(microsoft)\.com(?:/.*)? 15 | replace: $1,$2 16 | - name: microsoft_docs 17 | pattern: 'https://(?:docs)\.(?:microsoft)\.com[/]?(?: [a-zA-Z0-9\-\+_]+)(?:/)?([a-zA-Z0-9\-\+_]+)?(?:/)?([a-zA-Z0-9\-\+_]+)?(?:/.*)?' 18 | replace: $1,$2 19 | - name: youtube 20 | pattern: https://[[a-zA-Z0-9]+\.]?(youtube)\.com(?:/.*)? 21 | replace: $1 22 | - name: ebay 23 | pattern: https://[[a-zA-Z0-9]+\.]?(ebay)\.(com|de|fr)(?:/.*)? 24 | replace: $1 25 | - name: amazon 26 | pattern: https://[[a-zA-Z0-9]+\.]?(amazon)\.(com|de|fr)(?:/.*)? 27 | replace: $1 28 | - name: docker 29 | pattern: https://([a-zA-Z0-9]+)?[\.]?(docker)\.com(?:/.*)? 30 | replace: $1,$2 31 | - name: xbox 32 | pattern: https://[[a-zA-Z0-9]+\.]?(xbox)\.com(?:/.*)? 33 | replace: $1 34 | - name: github 35 | pattern: https://([ a-zA-Z0-9 ]+)?[ \. ]?(github)\.com[ / ]?([ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)? 36 | replace: $2,$3,$4 37 | - name: github.io 38 | pattern: https://([ a-zA-Z0-9 ]+)\.(github)\.io[ / ]?([ a-zA-Z0-9\-\+_ ]+)(?:/)?([ a-zA-Z0-9\-\+_ ]+)?(?:/.*)? 39 | replace: $1,$2,$3 -------------------------------------------------------------------------------- /src/Services/Linkding.Client/Automapper/LinkdingBookmarkProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using Core.Entities.Linkding; 3 | 4 | namespace Linkding.Client.Automapper; 5 | 6 | public class LinkdingBookmarkProfile : Profile 7 | { 8 | public LinkdingBookmarkProfile() 9 | { 10 | CreateMap(); 11 | CreateMap(); 12 | } 13 | } -------------------------------------------------------------------------------- /src/Services/Linkding.Client/Extensions/BookmarkExtensions.cs: -------------------------------------------------------------------------------- 1 | using Core.Entities.Linkding; 2 | 3 | namespace Linkding.Client.Extensions; 4 | 5 | public static class BookmarkExtensions 6 | { 7 | public static BookmarkCreatePayload MapToCreatePayload(this Bookmark bookmark) 8 | { 9 | var payload = new BookmarkCreatePayload(); 10 | payload.Title = bookmark.Title; 11 | payload.Description = bookmark.Description; 12 | payload.Url = bookmark.Url; 13 | payload.Unread = bookmark.Unread; 14 | payload.IsArchived = payload.IsArchived; 15 | 16 | foreach (var tagName in bookmark.TagNames) 17 | { 18 | payload.TagNames = payload.TagNames.Add(tagName); 19 | } 20 | 21 | return payload; 22 | } 23 | 24 | public static BookmarkUpdatePayload MapToUpdatePayload(this Bookmark bookmark) 25 | { 26 | var payload = new BookmarkUpdatePayload(); 27 | payload.Title = bookmark.Title; 28 | payload.Description = bookmark.Description; 29 | payload.Url = bookmark.Url; 30 | 31 | foreach (var tagName in bookmark.TagNames) 32 | { 33 | payload.TagNames = payload.TagNames.Add(tagName); 34 | } 35 | 36 | return payload; 37 | } 38 | } -------------------------------------------------------------------------------- /src/Services/Linkding.Client/Extensions/ServiceRegistrationExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Linkding.Client; 3 | using Linkding.Client.Options; 4 | using Microsoft.Extensions.Configuration; 5 | using Polly; 6 | using Polly.Extensions.Http; 7 | 8 | // ReSharper disable once CheckNamespace 9 | namespace Microsoft.Extensions.DependencyInjection; 10 | 11 | public static class ServiceRegistrationExtensions 12 | { 13 | public static IServiceCollection Add_Linkding_HttpClient(this IServiceCollection services, 14 | IConfiguration configuration) 15 | { 16 | var configSection = configuration.GetSection(LinkdingSettings.Position); 17 | services.Configure(configSection); 18 | services.AddHttpClient() 19 | .SetHandlerLifetime(TimeSpan.FromMinutes(5)) //Set lifetime to five minutes 20 | .AddPolicyHandler(GetRetryPolicy()); 21 | 22 | var assemblies = AppDomain.CurrentDomain.GetAssemblies(); 23 | services.AddAutoMapper(assemblies); 24 | 25 | return services; 26 | } 27 | 28 | static IAsyncPolicy GetRetryPolicy() 29 | { 30 | return HttpPolicyExtensions 31 | .HandleTransientHttpError() 32 | .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.NotFound) 33 | .WaitAndRetryAsync(6, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, 34 | retryAttempt))); 35 | } 36 | } -------------------------------------------------------------------------------- /src/Services/Linkding.Client/Extensions/System.cs: -------------------------------------------------------------------------------- 1 | namespace System; 2 | 3 | public static class System 4 | { 5 | public static DateTime CreateDateTime(this long ticks) 6 | { 7 | var dateTimeOffset = DateTimeOffset.FromUnixTimeSeconds(ticks); 8 | 9 | return dateTimeOffset.UtcDateTime; 10 | } 11 | 12 | public static DateTime CreateDateTime(this string ticksString) 13 | { 14 | if (long.TryParse(ticksString, out var dateAddedUnixEpoch)) 15 | { 16 | return dateAddedUnixEpoch.CreateDateTime(); 17 | } 18 | 19 | return default; 20 | } 21 | 22 | public static string GetYear(this DateTime date) 23 | { 24 | return date.ToString("yyyy"); 25 | } 26 | 27 | public static string GetYear(this long ticks) 28 | { 29 | var dateTime = ticks.CreateDateTime(); 30 | return dateTime.GetYear(); 31 | } 32 | 33 | public static string GetYear(this string ticksString) 34 | { 35 | var dateTime = ticksString.CreateDateTime(); 36 | return dateTime.GetYear(); 37 | } 38 | } -------------------------------------------------------------------------------- /src/Services/Linkding.Client/Extensions/SystemCollectionGenericExtesions.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable once CheckNamespace 2 | namespace System.Collections.Generic; 3 | 4 | public static class SystemCollectionGenericExtesions 5 | { 6 | public static IEnumerable Add(this IEnumerable e, T value) { 7 | foreach ( var cur in e) { 8 | yield return cur; 9 | } 10 | yield return value; 11 | } 12 | } -------------------------------------------------------------------------------- /src/Services/Linkding.Client/Linkding.Client.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | AnyCPU 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/Services/Linkding.Client/LinkdingService.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Headers; 2 | using System.Net.Http.Json; 3 | using System.Text; 4 | using System.Text.Json; 5 | using AutoMapper; 6 | using Core.Entities.Linkding; 7 | using Linkding.Client.Options; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace Linkding.Client; 11 | 12 | public class LinkdingService : ILinkdingService 13 | { 14 | private readonly LinkdingSettings _settings; 15 | private readonly IMapper _mapper; 16 | public readonly HttpClient _client; 17 | 18 | public LinkdingService(HttpClient client, IOptions settings, IMapper mapper) 19 | { 20 | _settings = settings.Value; 21 | _client = client; 22 | _mapper = mapper; 23 | _client.BaseAddress = new Uri(_settings.Url); 24 | _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", _settings.Key); 25 | } 26 | 27 | private LinkdingService(string url, string key) 28 | { 29 | _client = new HttpClient(); 30 | _client.BaseAddress = new Uri(url); 31 | _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", key); 32 | } 33 | 34 | public async Task> GetBookmarksAsync(int limit = 100, int offset = 0) 35 | { 36 | var bookmarks = new List(); 37 | 38 | var result = await GetBookmarkResultsAsync(limit, offset); 39 | if (result != null && result.Results?.Count() > 0) 40 | { 41 | bookmarks = result.Results; 42 | } 43 | 44 | return bookmarks; 45 | } 46 | 47 | public async Task> GetAllBookmarksAsync() 48 | { 49 | IEnumerable bookmarks = new List(); 50 | 51 | var result = await GetBookmarkResultsAsync(); 52 | if (result != null && result.Results?.Count() > 0) 53 | { 54 | bookmarks = result.Results; 55 | if (result.Count > 100) 56 | { 57 | while (!string.IsNullOrEmpty(result.Next)) 58 | { 59 | result = await GetBookmarkResultsAsync(result.Next); 60 | if (result.Results?.Count() > 0) 61 | { 62 | bookmarks = bookmarks.Concat(result.Results); 63 | } 64 | else 65 | { 66 | break; 67 | } 68 | } 69 | } 70 | } 71 | 72 | return bookmarks; 73 | } 74 | 75 | public async Task AddBookmarkCollectionAsync(IEnumerable bookmarks) 76 | { 77 | foreach (var bookmark in bookmarks) 78 | { 79 | var payload = _mapper.Map(bookmark); 80 | await AddBookmarkAsync(payload); 81 | } 82 | } 83 | 84 | public async Task AddBookmarkCollectionAsync(IEnumerable bookmarks) 85 | { 86 | foreach (var bookmark in bookmarks) 87 | { 88 | await AddBookmarkAsync(bookmark); 89 | } 90 | } 91 | 92 | public async Task AddBookmarkAsync(BookmarkCreatePayload bookmark) 93 | { 94 | var content = JsonSerializer.Serialize(bookmark); 95 | var requestContent = new StringContent(content, Encoding.UTF8, "application/json"); 96 | var uri = $"/api/bookmarks/"; 97 | 98 | try 99 | { 100 | var response = await _client.PostAsync(uri, requestContent); 101 | response.EnsureSuccessStatusCode(); 102 | } 103 | catch (Exception e) 104 | { 105 | Console.WriteLine(e); 106 | // throw; 107 | } 108 | 109 | var result = await _client.PostAsJsonAsync($"/api/bookmarks/", bookmark); 110 | if (result.IsSuccessStatusCode) 111 | { 112 | 113 | } 114 | else 115 | { 116 | 117 | } 118 | } 119 | 120 | public async Task UpdateBookmarkCollectionAsync(IEnumerable bookmarks) 121 | { 122 | foreach (var bookmark in bookmarks) 123 | { 124 | try 125 | { 126 | var payload = _mapper.Map(bookmark); 127 | await UpdateBookmarkAsync(bookmark.Id, payload); 128 | } 129 | catch (Exception e) 130 | { 131 | Console.WriteLine(e); 132 | // throw; 133 | } 134 | } 135 | } 136 | 137 | public async Task UpdateBookmarkAsync(int id, BookmarkUpdatePayload bookmark) 138 | { 139 | var content = JsonSerializer.Serialize(bookmark); 140 | var requestContent = new StringContent(content, Encoding.UTF8, "application/json"); 141 | var uri = $"/api/bookmarks/{id}/";// Path.Combine("api/bookmarks", $"{id}"); 142 | 143 | try 144 | { 145 | var response = await _client.PutAsync(uri, requestContent); 146 | response.EnsureSuccessStatusCode(); 147 | } 148 | catch (Exception e) 149 | { 150 | Console.WriteLine(e); 151 | // throw; 152 | } 153 | } 154 | 155 | public async Task GetBookmarkResultsAsync(int limit = 100, int offset = 0) 156 | { 157 | BookmarksResult bookmarkResult = null; 158 | 159 | var url = $"/api/bookmarks/"; 160 | 161 | bookmarkResult = await GetBookmarkResultsAsync(url); 162 | 163 | return bookmarkResult; 164 | } 165 | 166 | public async Task GetBookmarkResultsAsync(string url) 167 | { 168 | BookmarksResult bookmarkResult = null; 169 | 170 | bookmarkResult = await _client.GetFromJsonAsync(url); 171 | 172 | return bookmarkResult; 173 | } 174 | 175 | public static LinkdingService Create(string url, string key) 176 | { 177 | return new LinkdingService(url, key); 178 | } 179 | } -------------------------------------------------------------------------------- /src/Services/Linkding.Client/Options/LinkdingSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Linkding.Client.Options; 2 | 3 | public class LinkdingSettings 4 | { 5 | public const string Position = "Linkding"; 6 | 7 | public string Key { get; set; } 8 | public string Url { get; set; } 9 | public bool UpdateBookmarks { get; set; } = true; 10 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/Contracts/IAccessTokenProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Wallabag.Client.Contracts; 2 | 3 | public interface IAccessTokenProvider 4 | { 5 | Task GetToken(IEnumerable scopes); 6 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/Converters/DateTimeConverterForCustomStandard.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace Wallabag.Client.Converters; 6 | 7 | public class DateTimeConverterForCustomStandard : JsonConverter 8 | { 9 | public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 10 | { 11 | var dateTimeString = reader.GetString(); 12 | 13 | if (string.IsNullOrEmpty(dateTimeString)) 14 | { 15 | return DateTime.MinValue; 16 | } 17 | DateTime dt = DateTime.ParseExact(dateTimeString, "yyyy-MM-dd'T'HH:mm:ssK", 18 | CultureInfo.InvariantCulture, 19 | DateTimeStyles.AdjustToUniversal); 20 | 21 | return dt; 22 | } 23 | 24 | public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) 25 | { 26 | writer.WriteStringValue(value.ToString()); 27 | } 28 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/Converters/DateTimeOffsetConverterUsingDateTimeParse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Wallabag.Client.Converters; 5 | 6 | public class DateTimeOffsetConverterUsingDateTimeParse : JsonConverter 7 | { 8 | public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 9 | { 10 | var dateTimeString = reader.GetString(); 11 | return DateTimeOffset.Parse(dateTimeString); 12 | } 13 | 14 | public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) 15 | { 16 | writer.WriteStringValue(value.ToString()); 17 | } 18 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/Extensions/ServiceRegistrationExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Headers; 2 | using Microsoft.Extensions.Configuration; 3 | using Polly; 4 | using Polly.Extensions.Http; 5 | using Wallabag.Client; 6 | using Wallabag.Client.Contracts; 7 | using Wallabag.Client.OAuth; 8 | using Wallabag.Client.Options; 9 | 10 | // ReSharper disable once CheckNamespace 11 | namespace Microsoft.Extensions.DependencyInjection; 12 | 13 | public static class ServiceRegistrationExtensions 14 | { 15 | public static IServiceCollection Add_Wallabag_HttpClient(this IServiceCollection services, 16 | IConfiguration configuration) 17 | { 18 | var configSection = configuration.GetSection(WallabagSettings.Position); 19 | services.Configure(configSection); 20 | // services.AddScoped(); 21 | // services.AddScoped(); 22 | services.AddSingleton(); 23 | services.AddSingleton(); 24 | services.AddHttpClient() 25 | .SetHandlerLifetime(TimeSpan.FromMinutes(5)) //Set lifetime to five minutes 26 | .AddPolicyHandler(GetRetryPolicy()); 27 | 28 | // services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies()); 29 | 30 | return services; 31 | } 32 | 33 | static IAsyncPolicy GetRetryPolicy() 34 | { 35 | return HttpPolicyExtensions 36 | .HandleTransientHttpError() 37 | .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.NotFound) 38 | .WaitAndRetryAsync(6, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, 39 | retryAttempt))); 40 | } 41 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/Extensions/URIExtensions.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable once CheckNamespace 2 | namespace System; 3 | 4 | public static class URIExtensions 5 | { 6 | public static string AppendToURL(this string uri1, string uri2) 7 | { 8 | return AppendToUrlInternal(uri1, uri2); 9 | } 10 | 11 | public static string AppendToURL(this string baseURL, params string[] segments) 12 | { 13 | return AppendToUrlInternal(baseURL, segments); 14 | } 15 | 16 | private static string AppendToUrlInternal(this string baseURL, params string[] segments) 17 | { 18 | return string.Join("/", new[] { baseURL.TrimEnd('/') } 19 | .Concat(segments.Select(s => s.Trim('/')))); 20 | } 21 | 22 | public static Uri Append(this Uri uri, params string[] paths) 23 | { 24 | return new Uri(paths.Aggregate(uri.AbsoluteUri, (current, path) => string.Format("{0}/{1}", current.TrimEnd('/'), path.TrimStart('/')))); 25 | } 26 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/Models/WallabagEntry.cs: -------------------------------------------------------------------------------- 1 | namespace Wallabag.Client.Models; 2 | 3 | public class WallabagEntry : WallabagPayload 4 | { 5 | public int Id { get; set; } 6 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/Models/WallabagPayload.cs: -------------------------------------------------------------------------------- 1 | namespace Wallabag.Client.Models; 2 | 3 | public class WallabagPayload 4 | { 5 | public string Url { get; set; } 6 | public string Title { get; set; } 7 | public string Tags { get; set; } 8 | public int Archive { get; set; } 9 | public int Starred { get; set; } 10 | public string Content { get; set; } 11 | public string Language { get; set; } 12 | public string PreviewPicture { get; set; } 13 | public DateTime PublishedAt { get; set; } 14 | public string Authors { get; set; } 15 | public int Publich { get; set; } 16 | public string OriginUrl { get; set; } 17 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/OAuth/AuthenticationClient.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | using Microsoft.Extensions.Options; 3 | using Wallabag.Client.Options; 4 | 5 | namespace Wallabag.Client.OAuth; 6 | 7 | public record WallabagToken(string token_type, string access_token, string refresh_token, int expires_in, string scope); 8 | 9 | public class AuthenticationClient 10 | { 11 | protected const string AuthPath = "/oauth/v2/token"; 12 | 13 | public readonly HttpClient _client; 14 | public readonly WallabagSettings _settings; 15 | 16 | public AuthenticationClient(HttpClient client, IOptions settings) 17 | { 18 | _client = client; 19 | _settings = settings.Value; 20 | // var baseAdress = new Uri(_settings.Url); 21 | // client.BaseAddress = baseAdress.Append(AuthPath); 22 | client.BaseAddress = new Uri(_settings.Url); 23 | } 24 | 25 | public async Task Authenticate(IEnumerable scopes = null) 26 | { 27 | var parameters = new List> 28 | { 29 | new("client_id", _settings.ClientId), 30 | new("client_secret", _settings.ClientSecret), 31 | new("grant_type", _settings.GrandType), 32 | new("username", _settings.Username), 33 | new("password", _settings.Password) 34 | }; 35 | 36 | if (scopes != null && scopes.Count() > 0) 37 | { 38 | parameters.Add(new("scope", string.Join(" ", scopes))); 39 | } 40 | var response = await _client.PostAsync("oauth/v2/token", new FormUrlEncodedContent(parameters)); 41 | 42 | response.EnsureSuccessStatusCode(); 43 | var authentication = await response.Content.ReadFromJsonAsync(); 44 | if (authentication == null) 45 | { 46 | throw new HttpRequestException("Could not retrieve authentication data."); 47 | } 48 | return authentication; 49 | } 50 | public async Task RefreshToken(string refreshToken, IEnumerable scopes = null) 51 | { 52 | var parameters = new List> 53 | { 54 | new("client_id", _settings.ClientId), 55 | new("client_secret", _settings.ClientSecret), 56 | new("grant_type", "refresh_token"), 57 | new("refresh_token", refreshToken), 58 | new("username", _settings.Username), 59 | new("password", _settings.Password) 60 | }; 61 | 62 | if (scopes != null && scopes.Count() > 0) 63 | { 64 | parameters.Add(new("scope", string.Join(" ", scopes))); 65 | } 66 | 67 | var response = await _client.PostAsync("oauth/v2/token", new FormUrlEncodedContent(parameters)); 68 | 69 | response.EnsureSuccessStatusCode(); 70 | var authentication = await response.Content.ReadFromJsonAsync(); 71 | if (authentication == null) 72 | { 73 | throw new HttpRequestException("Could not retrieve authentication data."); 74 | } 75 | return authentication; 76 | } 77 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/OAuth/OAuthTokenProvider.cs: -------------------------------------------------------------------------------- 1 | using Wallabag.Client.Contracts; 2 | 3 | namespace Wallabag.Client.OAuth; 4 | 5 | public class OAuthTokenProvider : IAccessTokenProvider 6 | { 7 | protected record TokenCache(string access_token, DateTime expires); 8 | 9 | private readonly AuthenticationClient _client; 10 | 11 | protected Dictionary cache = new (); 12 | 13 | public OAuthTokenProvider(AuthenticationClient client) 14 | { 15 | _client = client; 16 | } 17 | 18 | public async Task GetToken(IEnumerable scopes = null) 19 | { 20 | string cacheKey = "wallabag+token"; 21 | 22 | if (scopes != null && scopes.Count() > 0) 23 | { 24 | cacheKey = string.Join('+', scopes); 25 | } 26 | 27 | if (cache.ContainsKey(cacheKey)) 28 | { 29 | var tokenCache = cache[cacheKey]; 30 | if (tokenCache.expires > DateTime.Now) 31 | { 32 | return tokenCache.access_token; 33 | } 34 | else 35 | { 36 | cache.Remove(cacheKey); 37 | } 38 | } 39 | var auth = await _client.Authenticate(scopes); 40 | cache.Add(cacheKey, new TokenCache(auth.access_token, DateTime.Now.AddSeconds(auth.expires_in))); 41 | return auth.access_token; 42 | } 43 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/Options/WallabagSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Wallabag.Client.Options; 2 | 3 | public class WallabagSettings 4 | { 5 | public const string Position = "Wallabag"; 6 | 7 | public string Url { get; set; } = "https://app.wallabag.it"; 8 | public string Username { get; set; } 9 | public string Password { get; set; } 10 | public string ClientId { get; set; } 11 | public string ClientSecret { get; set; } 12 | public string GrandType { get; set; } = "password"; 13 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/Wallabag.Client.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | AnyCPU 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/WallabagServiceBase.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Headers; 2 | using System.Net.Http.Json; 3 | using System.Text.Json; 4 | using Core.Abstraction; 5 | using Microsoft.Extensions.Options; 6 | using Wallabag.Client.Contracts; 7 | using Wallabag.Client.Converters; 8 | using Wallabag.Client.Options; 9 | 10 | namespace Wallabag.Client; 11 | 12 | public partial class WallabagService : IWallabagService 13 | { 14 | private readonly WallabagSettings _settings; 15 | private IAccessTokenProvider _accessTokenProvider; 16 | public readonly HttpClient _client; 17 | 18 | public WallabagService(HttpClient client, IOptions settings, 19 | IAccessTokenProvider accessTokenProvider) 20 | { 21 | _client = client; 22 | _accessTokenProvider = accessTokenProvider; 23 | _settings = settings.Value; 24 | _client.BaseAddress = new Uri(_settings.Url); 25 | } 26 | 27 | public async Task GetAuthenticationHeaderAsync(IEnumerable scopes = null) 28 | { 29 | return new AuthenticationHeaderValue("Bearer", await _accessTokenProvider.GetToken(scopes)); 30 | } 31 | 32 | public async Task GetAsync(string endpoint, IEnumerable scopes = null, 33 | bool httpCompletionResponseContentRead = false) 34 | { 35 | var request = new HttpRequestMessage(HttpMethod.Get, endpoint); 36 | request.Headers.Authorization = await GetAuthenticationHeaderAsync(scopes); 37 | 38 | HttpResponseMessage response = null; 39 | 40 | if (httpCompletionResponseContentRead) 41 | { 42 | response = await _client.SendAsync(request, HttpCompletionOption.ResponseContentRead); 43 | } 44 | else 45 | { 46 | response = await _client.SendAsync(request); 47 | } 48 | 49 | response.EnsureSuccessStatusCode(); 50 | return response; 51 | } 52 | 53 | public async Task GetJsonAsync(string endpoint, IEnumerable scopes = null) 54 | { 55 | var response = await GetAsync(endpoint, scopes); 56 | 57 | return await response.Content.ReadFromJsonAsync(); 58 | } 59 | 60 | public async Task PostWithFormDataAsnyc(string endpoint, FormUrlEncodedContent content, 61 | IEnumerable scopes = null) 62 | { 63 | using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); 64 | request.Content = content; 65 | 66 | return await PostAsync(endpoint, request, scopes); 67 | } 68 | 69 | public async Task PostAsync(string endpoint, HttpContent content, 70 | IEnumerable scopes = null) 71 | { 72 | using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); 73 | request.Content = content; 74 | 75 | return await PostAsync(endpoint, request, scopes); 76 | } 77 | 78 | public async Task PostAsync(string endpoint, HttpRequestMessage request, 79 | IEnumerable scopes = null) 80 | { 81 | request.Headers.Authorization = await GetAuthenticationHeaderAsync(scopes); 82 | var response = await _client.SendAsync(request); 83 | response.EnsureSuccessStatusCode(); 84 | return response; 85 | } 86 | 87 | public async Task PutAsync(string endpoint, HttpContent content, 88 | IEnumerable scopes = null) 89 | { 90 | using var request = new HttpRequestMessage(HttpMethod.Put, endpoint); 91 | request.Headers.Authorization = await GetAuthenticationHeaderAsync(scopes); 92 | request.Content = content; 93 | var response = await _client.SendAsync(request); 94 | response.EnsureSuccessStatusCode(); 95 | return response; 96 | } 97 | 98 | public async Task DeleteAsync(string endpoint, IEnumerable scopes = null, 99 | HttpContent content = null) 100 | { 101 | using var request = new HttpRequestMessage(HttpMethod.Delete, endpoint); 102 | request.Headers.Authorization = await GetAuthenticationHeaderAsync(scopes); 103 | request.Content = content; 104 | var response = await _client.SendAsync(request); 105 | response.EnsureSuccessStatusCode(); 106 | return response; 107 | } 108 | } -------------------------------------------------------------------------------- /src/Services/Wallabag.Client/WallabagServiceEntries.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | using Core.Entities.Wallabag; 3 | using Wallabag.Client.Models; 4 | 5 | namespace Wallabag.Client; 6 | 7 | public partial class WallabagService 8 | { 9 | public async Task> GetEntries(string format = "json", int limit = 50, bool full = false) 10 | { 11 | var bookmarks = new List(); 12 | var url = $"/api/entries.{format}?perPage={limit}"; 13 | if (!full) 14 | { 15 | url = $"{url}&detail=metadata"; 16 | } 17 | 18 | var allQuery = await GetJsonAsync(url); 19 | 20 | if (allQuery != null && allQuery.Embedded != null && allQuery.Embedded.Items != null && allQuery.Embedded.Items.Count() > 0) 21 | { 22 | bookmarks = allQuery.Embedded.Items; 23 | 24 | if (allQuery.Total > limit) 25 | { 26 | while (allQuery.QueryLinks.Next != null && !string.IsNullOrEmpty(allQuery.QueryLinks.Next.Href)) 27 | { 28 | // url = allQuery.QueryLinks.Next.Href.Replace(_settings.Url, ""); 29 | url = allQuery.QueryLinks.Next.Href; 30 | if (_client.BaseAddress.Scheme == "https") 31 | { 32 | url = allQuery.QueryLinks.Next.Href.Replace("http://", "https://"); 33 | } 34 | 35 | allQuery = await GetJsonAsync(url); 36 | bookmarks.AddRange(allQuery.Embedded.Items); 37 | } 38 | } 39 | } 40 | 41 | return bookmarks; 42 | } 43 | 44 | public async Task GetEntryById(int id, string format = "json") 45 | { 46 | var url = $"/api/entries/{id}.{format}"; 47 | var item = await GetJsonAsync(url); 48 | 49 | return item; 50 | } 51 | 52 | public async Task AddEntryByUrl(string url, IEnumerable tags = null, string format = "json") 53 | { 54 | var endpoint = $"/api/entries.{format}"; 55 | 56 | var keyVals = new Dictionary(); 57 | keyVals.Add("url", url); 58 | if (tags != null && tags.Count() > 0) 59 | { 60 | keyVals.Add("tags", string.Join(",", tags)); 61 | } 62 | 63 | var content = new FormUrlEncodedContent(keyVals); 64 | var response = await PostWithFormDataAsnyc(endpoint, content); 65 | 66 | var item = await response.Content.ReadFromJsonAsync(); 67 | return item; 68 | } 69 | 70 | // private async Task GetBookmarkResultsAsync(int limit = 100, int offset = 0) 71 | // { 72 | // WallabagEntry bookmarkResult = null; 73 | // 74 | // var url = $"/api/bookmarks/"; 75 | // 76 | // bookmarkResult = await GetBookmarkResultsAsync(url); 77 | // 78 | // return bookmarkResult; 79 | // } 80 | // 81 | // private async Task GetBookmarkResultsAsync(string url) 82 | // { 83 | // WallabagEntry bookmarkResult = null; 84 | // 85 | // bookmarkResult = await _client.GetFromJsonAsync(url); 86 | // 87 | // return bookmarkResult; 88 | // } 89 | 90 | } -------------------------------------------------------------------------------- /src/Wallabag/Extensions/ServiceRegistrationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Wallabag.Options; 2 | using Wallabag.Settings; 3 | 4 | // ReSharper disable once CheckNamespace 5 | namespace Microsoft.Extensions.DependencyInjection; 6 | 7 | public static class ServiceRegistrationExtensions 8 | { 9 | public static IServiceCollection Add_Wallabag_Worker(this IServiceCollection services, 10 | IConfiguration configuration) 11 | { 12 | var configSection = configuration.GetSection(WorkerSettings.Position); 13 | services.Configure(configSection); 14 | services.AddSingleton(); 15 | 16 | return services; 17 | } 18 | } -------------------------------------------------------------------------------- /src/Wallabag/Handler/LinkdingBookmarkToWallabagHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.RegularExpressions; 3 | using Core.Entities.Wallabag; 4 | using Core.Handler; 5 | using Linkding.Client; 6 | using Wallabag.Client; 7 | using Wallabag.Settings; 8 | 9 | namespace Wallabag.Handler 10 | { 11 | public class LinkdingBookmarkToWallabagHandler : ISyncTaskHandler 12 | { 13 | public Type HandlerType { get; } = typeof(WallabagService); 14 | public string Command { get; } = "LinkdingBookmarkToWallabag"; 15 | 16 | public async Task ProcessAsync(IEnumerable items, WallabagService destinationService, 17 | ILinkdingService linkdingService, 18 | ILogger logger, IConfiguration configuration) 19 | { 20 | var wallabagsNormalized = new Dictionary(); 21 | var updatedWallabags = new Dictionary>(); 22 | var wallabagToRemove = new List(); 23 | var linkdingBookmarks = await linkdingService.GetAllBookmarksAsync(); 24 | 25 | if (linkdingBookmarks != null && linkdingBookmarks.Count() > 0) 26 | { 27 | var settings = SettingsService.Settings; 28 | 29 | // var settingsString = JsonSerializer.Serialize(settings); 30 | // logger.LogInformation($"settings: {settingsString}"); 31 | 32 | var tagName = configuration.GetValue("Worker:SyncTag"); 33 | 34 | linkdingBookmarks = 35 | linkdingBookmarks.Where(x => x.TagNames.Contains(tagName)).OrderBy(x => x.DateAdded); 36 | 37 | Regex r = null; 38 | Match m = null; 39 | foreach (var bookmark in linkdingBookmarks) 40 | { 41 | var cleanUrl = 42 | bookmark.Url.Replace( 43 | "?utm_source=share&utm_medium=android_app&utm_name=androidcss&utm_term=2&utm_content=share_button", 44 | ""); 45 | // var existingElement = items.FirstOrDefault(x => x.Url.ToLower() == cleanUrl.ToLower()); 46 | var existingElement = items.FirstOrDefault(x => 47 | x.Url.ToLower() == cleanUrl.ToLower() || x.OriginUrl?.ToLower() == cleanUrl.ToLower()); 48 | if (existingElement == null) 49 | { 50 | var addToWallabag = true; 51 | foreach (var p in settings.excludedDomains) 52 | { 53 | r = new Regex(p.pattern, RegexOptions.IgnoreCase); 54 | m = r.Match(cleanUrl); 55 | 56 | if (m.Success) 57 | { 58 | addToWallabag = false; 59 | break; 60 | } 61 | } 62 | 63 | if (addToWallabag && !updatedWallabags.ContainsKey(cleanUrl)) 64 | { 65 | updatedWallabags.Add(cleanUrl, 66 | bookmark.TagNames.Where(x => !x.Equals(tagName, StringComparison.OrdinalIgnoreCase))); 67 | } 68 | } 69 | } 70 | } 71 | else 72 | { 73 | logger.LogInformation($"no bookmarks found"); 74 | } 75 | 76 | if (updatedWallabags.Count() > 0) 77 | { 78 | logger.LogInformation($"Detected {updatedWallabags.Count()} bookmarks... Start syncing"); 79 | 80 | foreach (var (url, tags) in updatedWallabags) 81 | { 82 | var result = await destinationService.AddEntryByUrl(url, tags); 83 | 84 | if (result.ReadingTime == 0) 85 | { 86 | wallabagToRemove.Add(result.Id); 87 | } 88 | } 89 | 90 | logger.LogInformation($"{updatedWallabags.Count()} bookmarks synced"); 91 | } 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /src/Wallabag/Options/WorkerSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Wallabag.Options; 2 | 3 | public class WorkerSettings 4 | { 5 | public const string Position = "Worker"; 6 | 7 | public int Interval { get; set; } = 0; 8 | 9 | public string SyncTag { get; set; } = "readlater"; 10 | } -------------------------------------------------------------------------------- /src/Wallabag/Program.cs: -------------------------------------------------------------------------------- 1 | using Wallabag; 2 | 3 | IHost host = Host.CreateDefaultBuilder(args) 4 | .ConfigureServices((ctx, services) => 5 | { 6 | services.Add_Wallabag_HttpClient(ctx.Configuration); 7 | services.Add_Linkding_HttpClient(ctx.Configuration); 8 | services.Add_Wallabag_Worker(ctx.Configuration); 9 | services.AddHostedService(); 10 | }).ConfigureHostConfiguration((builder) => 11 | { 12 | builder 13 | .AddEnvironmentVariables() 14 | #if DEBUG 15 | .AddJsonFile("appsettings.json") 16 | .AddJsonFile("appsettings.Development.json") 17 | #endif 18 | .AddUserSecrets(true) 19 | .AddCommandLine(args); 20 | }) 21 | .Build(); 22 | 23 | await host.RunAsync(); -------------------------------------------------------------------------------- /src/Wallabag/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "WallabagWorker": { 4 | "commandName": "Project", 5 | "dotnetRunMessages": true, 6 | "environmentVariables": { 7 | "DOTNET_ENVIRONMENT": "Development" 8 | } 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Wallabag/Settings/SettingsService.cs: -------------------------------------------------------------------------------- 1 | using YamlDotNet.Serialization; 2 | using YamlDotNet.Serialization.NamingConventions; 3 | 4 | namespace Wallabag.Settings; 5 | 6 | public class SettingYaml 7 | { 8 | public List excludedDomains { get; set; } = new (); 9 | } 10 | 11 | public class ExcludedDomainPattern 12 | { 13 | public string name { get; set; } 14 | public string pattern { get; set; } 15 | } 16 | 17 | public class SettingsService 18 | { 19 | private const string fileName = "data/config.yml"; 20 | 21 | private static SettingYaml _settings = null; 22 | 23 | public static SettingYaml Settings 24 | { 25 | get 26 | { 27 | if (_settings == null) 28 | { 29 | Initialize(); 30 | } 31 | 32 | return _settings; 33 | } 34 | private set 35 | { 36 | _settings = value; 37 | } 38 | } 39 | 40 | private static void Initialize() 41 | { 42 | var filePath = Path.Combine(Environment.CurrentDirectory, fileName); 43 | var fileInfo = new FileInfo(filePath); 44 | 45 | if (fileInfo.Exists) 46 | { 47 | var deserializer = new DeserializerBuilder() 48 | .WithNamingConvention(CamelCaseNamingConvention.Instance) // see height_in_inches in sample yml 49 | .Build(); 50 | 51 | var yml = File.ReadAllText(fileInfo.FullName); 52 | 53 | Settings = deserializer.Deserialize(yml); 54 | } 55 | else 56 | { 57 | Settings = new SettingYaml(); 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/Wallabag/Wallabag.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | dotnet-WallabagWorker-D5E52F5F-C642-4BF8-9FD7-8C6C417B0D3A 8 | Linux 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | PreserveNewest 25 | 26 | 27 | PreserveNewest 28 | 29 | 30 | 31 | 32 | 33 | Never 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/Wallabag/Worker.cs: -------------------------------------------------------------------------------- 1 | using Core.Abstraction; 2 | using Core.Entities.Linkding; 3 | using Core.Handler; 4 | using Linkding.Client; 5 | using Linkding.Client.Options; 6 | using Microsoft.Extensions.Options; 7 | using Wallabag.Client; 8 | using Wallabag.Client.Options; 9 | using Wallabag.Options; 10 | 11 | namespace Wallabag; 12 | 13 | public class Worker : BackgroundService 14 | { 15 | private readonly IHostApplicationLifetime _hostApplicationLifetime; 16 | private readonly ILogger _logger; 17 | private readonly LinkdingService _linkdingService; 18 | private readonly WallabagService _wallabagService; 19 | private readonly LinkdingSettings _linkdingSettings; 20 | private readonly WallabagSettings _wallabagSettings; 21 | private readonly WorkerSettings _settings; 22 | private readonly IConfiguration _configuration; 23 | 24 | 25 | public Worker(ILogger logger, LinkdingService linkdingService, WallabagService wallabagService, 26 | IOptions linkdingSettings, IOptions settings, IOptions wallabagSettings, IConfiguration configuration, IHostApplicationLifetime hostApplicationLifetime) 27 | { 28 | _logger = logger; 29 | _linkdingService = linkdingService; 30 | _wallabagService = wallabagService; 31 | _configuration = configuration; 32 | _hostApplicationLifetime = hostApplicationLifetime; 33 | _wallabagSettings = wallabagSettings.Value; 34 | _settings = settings.Value; 35 | _linkdingSettings = linkdingSettings.Value; 36 | } 37 | 38 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 39 | { 40 | while (!stoppingToken.IsCancellationRequested) 41 | { 42 | _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); 43 | 44 | await RunSyncWallabag(); 45 | int delay = _settings.Interval * 60000; 46 | 47 | if (delay > 0) 48 | { 49 | _logger.LogInformation($"Worker paused for: {_settings.Interval} minutes"); 50 | 51 | await Task.Delay(delay, stoppingToken); 52 | } 53 | else 54 | { 55 | _logger.LogInformation($"Delay was set to '0' --> stopping worker"); 56 | _hostApplicationLifetime.StopApplication(); 57 | } 58 | } 59 | } 60 | 61 | public async Task RunSyncWallabag() 62 | { 63 | if (!string.IsNullOrEmpty(_wallabagSettings.Url)) 64 | { 65 | 66 | _logger.LogInformation($"Starting updating bookmarks for {_linkdingSettings.Url}"); 67 | _logger.LogInformation("Collectin LinkdingService Handler"); 68 | var wallabagHandlers = AppDomain.CurrentDomain.GetAssemblies() 69 | .SelectMany(s => s.GetTypes()) 70 | .Where(p => typeof(ISyncTaskHandler).IsAssignableFrom(p) && p.IsClass); 71 | 72 | if (wallabagHandlers != null && wallabagHandlers.Count() > 0) 73 | { 74 | var wallabags = await _wallabagService.GetEntries(); 75 | 76 | foreach (var handler in wallabagHandlers) 77 | { 78 | ISyncTaskHandler handlerInstance = null; 79 | try 80 | { 81 | handlerInstance = (ISyncTaskHandler) Activator.CreateInstance(handler); 82 | 83 | await handlerInstance.ProcessAsync(wallabags, _wallabagService, _linkdingService, _logger, _configuration); 84 | } 85 | catch (Exception e) 86 | { 87 | Console.WriteLine(e); 88 | throw; 89 | } 90 | } 91 | } 92 | 93 | _logger.LogInformation($"no bookmarks found in {_linkdingSettings.Url}"); 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /src/Wallabag/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.Hosting.Lifetime": "Information" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Wallabag/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.Hosting.Lifetime": "Information" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/Wallabag/data/config.yml: -------------------------------------------------------------------------------- 1 | excludedDomains: 2 | - name: youtube 3 | pattern: https://[[a-zA-Z0-9]+\.]?(youtube)\.com(?:/.*)? 4 | - name: ebay 5 | pattern: https://[[a-zA-Z0-9]+\.]?(ebay)\.(com|de|fr)(?:/.*)? 6 | - name: amazon 7 | pattern: https://[[a-zA-Z0-9]+\.]?(amazon)\.(com|de|fr)(?:/.*)? --------------------------------------------------------------------------------